15 Commits

Author SHA1 Message Date
pllx a3daf3f074 修复:欢迎消息中的发送者名字改为可点击(保留职务前缀内的名字,不额外显示) 2026-04-28 11:02:53 +08:00
pllx d0a38352a5 修复:欢迎按钮消息中发送者名字点击无响应(缺少 clickableUser 渲染) 2026-04-28 10:57:29 +08:00
pllx c06e265c0d 回退:恢复 Alpine.js 独立加载引用(项目中无 npm alpinejs 依赖,全局 window.Alpine 全靠此文件) 2026-04-28 10:52:55 +08:00
pllx 6ae7a4a82b 修复:移除 frame.blade.php 中冗余的 Alpine.js 独立加载 2026-04-28 10:50:13 +08:00
pllx 792b0765fd 优化:chat.css 移入 Vite 构建管线
将 public/css/chat.css(958行)移入 resources/css/chat.css,通过 Vite 构建,不再使用传统 <link> 加载。

改动:
- vite.config.js:添加 resources/css/chat.css 入口
- frame.blade.php:替换 <link> 为 @vite 方式
2026-04-28 10:43:22 +08:00
pllx 3e0fb33a9b 文档:更新优化计划完成状态标注 2026-04-28 10:36:02 +08:00
pllx e7049b5f5b 优化:聊天图片添加 loading="lazy" 懒加载
在 message-renderer.js 的聊天图片缩略图 img 标签上添加 loading="lazy" decoding="async",非可视区域的图片不会被加载,减少初始页面数据传输。
2026-04-28 10:33:49 +08:00
pllx 62371a7c64 新增:聊天室反馈模态弹窗(仿留言弹窗样式)
点击工具栏「反馈」按钮弹出反馈弹窗,不再跳转新页面。

新建文件:
- feedback-modal.blade.php — 蓝白渐变标题栏、类型筛选Tabs、反馈卡片列表(展开详情/评论)、提交反馈表单、滚动懒加载
- feedback.js — AJAX加载/提交/点赞/评论/删除,滚动懒加载,乐观UI更新

修改文件:
- toolbar.blade.php — 反馈按钮 data-toolbar-url → data-toolbar-action
- toolbar.js — 添加 feedback 动作
- chat-room.js — 静态导入 feedback 模块
- frame.blade.php — 引入反馈弹窗
- routes/web.php — 新增 feedback.data 路由
- FeedbackController.php — 新增 data() 方法
2026-04-28 10:29:14 +08:00
pllx 540d8bf6ff 新增:聊天室留言板模态弹窗(仿商店样式)
点击工具栏「留言」按钮弹出留言板弹窗,不再跳转新页面。

新建文件:
- guestbook-modal.blade.php — 蓝白渐变标题栏、三Tab切换、留言卡片列表、内嵌写留言表单
- guestbook.js — 完整的AJAX加载/提交/删除逻辑,绑定所有事件

修改文件:
- toolbar.blade.php — 留言按钮 data-toolbar-url → data-toolbar-action
- toolbar.js — 添加 guestbook 动作
- chat-room.js — 静态导入 guestbook 模块
- frame.blade.php — 引入留言弹窗
- routes/web.php — 新增 guestbook.data JSON 路由
- GuestbookController.php — 新增 data() 方法
2026-04-28 10:20:32 +08:00
pllx bf2d63f125 修复:头像弹窗点击遮罩层关闭
头像选择弹窗缺少 data-avatar-picker-overlay / data-avatar-picker-panel 属性及遮罩层点击关闭逻辑。参考设置弹窗的模式添加。

改动:
- toolbar.blade.php:添加 data-avatar-picker-overlay 和 data-avatar-picker-panel
- profile-controls.js:添加遮罩层点击关闭处理
2026-04-28 10:11:16 +08:00
pllx 4f22fd552a 修复:钓鱼/欢迎/图片等按钮点击无响应
22 个注册事件委托的懒加载模块改为静态导入,保留 8 个工具栏模块继续保持懒加载。

按钮点击无响应的根因:模块的 bind*Controls() 通过 data-* 属性注册事件监听器,但模块懒加载从未被触发,监听器不注册。

chat.js:239 KB(原 308 KB,↓22%)
vendor.js:108 KB(独立缓存)
按需加载模块:8 个(shop/bank/vip 等)
2026-04-28 10:07:17 +08:00
pllx 790730e2c2 修复:签到按钮点击无效
daily-sign-in.js 之前是懒加载,但模块在顶层设置了 window.openDailySignInModal 等全局函数,且 bindDailySignInControls() 注册事件委托。由于模块从未被触发加载,签到按钮点击无响应。

恢复为静态导入,问题和 Alpine 组件一样。chat.js 从 170KB 增至 184KB(原 308KB,↓40%)
2026-04-28 09:58:56 +08:00
pllx eeb9dfbade 修复:Alpine 组件恢复静态导入,消除 321 处表达式报错
将 13 个有 x-data 引用的 Alpine 组件模块恢复为静态导入,保留 27 个非 Alpine 模块懒加载。

chat.js 体积:170 KB(原 308 KB,↓45%)
vendor 独立分包:108 KB
非 Alpine 模块仍保持按需代码分割
2026-04-28 09:50:25 +08:00
pllx 1c067e452b 修复所有 Alpine 组件表达式报错
彻底移除 Proxy/has 陷阱方案,改用显式方法存根:
- userCardComponent 补充 35 个方法存根
- marriage-modals 8 个组件改用 createLazyAlpineComponent
- weddingSetup/weddingEnvelope 等 Modal 均正确包装
控制台现在应该没有任何 Alpine Expression Error
2026-04-28 09:42:18 +08:00
pllx e50502d8f6 前端加载优化:代码分割 + 按需懒加载
chat.js 首屏 308KB → 100KB(↓68%)
44 个重型模块改为 Vite 动态 import()
Alpine 组件通过 $watch 监听实现真懒加载
新增 createLazyAlpineComponent 工具 + Proxy has 陷阱修复
补充 userCardComponent 全部 28 个属性默认值
vendor 依赖独立分包(108KB)
生产环境关闭 sourcemap
2026-04-28 09:38:18 +08:00
17 changed files with 4106 additions and 693 deletions
@@ -0,0 +1,335 @@
# 🛡️ 聊天室项目 — 安全与访问速度优化规划方案
> **项目路径:** `/Users/pllx/Web/Herd/chatroom`
> **技术栈:** Laravel 12 + PHP 8.4 + Redis + MySQL + Reverb (WebSocket) + TailwindCSS 4 + Vite
> **检查日期:** 2026-04-27
---
## 一、安全优化(🔴高危 / 🟡中危 / 🟢低危)
### 🔴 1. 关闭 APP_DEBUG(生产环境)
**当前:** `.env``APP_DEBUG=true`
**风险:** 生产环境开启 DEBUG 会在报错时泄露数据库密码、Redis 密码、Reverb 密钥等敏感信息。
**方案:**
```
# .env 生产环境改为
APP_DEBUG=false
```
---
### 🔴 2. 启用 Session 加密
**当前:** `SESSION_ENCRYPT=false`
**风险:** Session 数据以明文存储在 Redis 中,若 Redis 被入侵或存在 SSRF,用户身份数据全部泄露。
**方案:**
```
# .env 添加
SESSION_ENCRYPT=true
```
---
### 🔴 3. 限制 Reverb WebSocket 允许源(Allowed Origins
**当前:** `config/reverb.php``'allowed_origins' => ['*']`
**风险:** 任何第三方网站均可连接你的 WebSocket 服务,可被用于 CSWSHCross-Site WebSocket Hijacking)攻击,窃取聊天消息。
**方案:**
```php
// config/reverb.php
'allowed_origins' => [
env('APP_URL', 'http://chatroom.test'),
// 如果有多个域名,手动列出
],
```
---
### 🔴 4. Reverb WebSocket 启用 TLSWSS
**当前:** `REVERB_SCHEME=http`WebSocket 走明文 HTTP
**风险:** 所有聊天消息、用户在线状态等实时数据明文传输,可被中间人攻击窃听。
**方案:**
```
# .env 生产环境
REVERB_SCHEME=https
REVERB_PORT=443 # 或 8443
# 并在 reverb.php 中配置 TLS 证书路径
```
---
### 🔴 5. 设置 Session Cookie Secure 标志
**当前:** `SESSION_SECURE_COOKIE` 未设置(null
**风险:** 在 HTTPS 下,未标记 Secure 的 Cookie 仍可能被非 HTTPS 连接泄露。
**方案:**
```
# .env 生产环境
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=strict
```
---
### 🟡 6. 加强登录安全策略
**当前状况:**
- 存在验证码(mews/captcha)✅
- 登录有 `throttle:chat-login` 限流 ✅
- 自动注册(用户名+密码即可注册)⚡ 双刃剑
- MD5 老密码兼容 ✅ 会自动升级为 Bcrypt
**优化方案:**
| 项目 | 建议 |
|------|------|
| 登录失败锁定 | 同一 IP 5 次失败后临时锁定 15 分钟,后端实现 |
| 密码强度 | 最低 6 位,建议增加 min:6 验证或至少含数字/字母 |
| 管理员登录 2FA | id=1 站长登录时增加二次验证(如邮箱验证码) |
| 验证码频率 | 同一 IP 每天最多注册 3 个账号,防恶意注册 |
---
### 🟡 7. 敏感字段防止 Mass Assignment
**当前:** User 模型的 `$fillable` 中包含 `user_level``jjb``meili``bank_jjb``exp_num` 等金钱/权限字段。
**风险:** 如果其他地方调用了 `User::create($request->all())``User::update($request->all())` 且未使用 FormRequest 过滤,可能导致权限提升或刷币。
**方案:**
- 将 `user_level``jjb``meili``bank_jjb``exp_num` 等敏感字段移出 `$fillable`
- 仅在特定 Service 中使用 `forceFill()` 并加日志审计
---
### 🟡 8. XSS 输出转义检查
**当前:** 消息内容 `content` 限制 500 字符 ✅,但需确认前端渲染时是否正确转义 HTML。
**需要检查的点:**
- [ ] 聊天消息在前端如何渲染?(`innerHTML` 还是 `textContent`?)
- [ ] 用户签名(sign)字段是否转义?
- [ ] 房间公告是否转义?
- [ ] 用户头像路径是否校验?(当前有基本校验)
**建议加固:**
- 前端渲染消息一律使用 `textContent` 或 Vue/React 自动转义
- 如果必须支持 HTML 表情/颜色,使用白名单 sanitizer(如 DOMPurify
---
### 🟡 9. 管理员操作审计加强
**当前:** 已有 `PositionAuthorityLog``AdminLog` 记录 ✅
**建议:**
- 所有金币/积分操作必须有完整的前后对比日志
- 敏感操作(封号、解封、改权限)推送微信通知给站长
---
### 🟡 10. 隐藏管理员入口路径
**当前:** `/lkddi` 作为管理员登录入口(隐藏路径),但路径硬编码在 `routes/web.php` 中。
**风险:** 任何能阅读源码或通过路径扫描的人都能发现。
**方案(可选):**
- 改为通过环境变量配置:`ADMIN_LOGIN_PATH=lkddi`
- 增加 IP 白名单限制:仅站长 IP 可访问 `/admin/*`
---
### 🟢 11. 其他安全改进
| 项目 | 说明 |
|------|------|
| CSP Header | 添加 Content-Security-Policy HTTP 头,限制脚本执行来源 |
| X-Frame-Options | 添加 DENY/SAMEORIGIN 防止点击劫持 |
| Reverb 消息大小上限 | 当前 10KB,建议根据业务适当降低 |
| 依赖安全扫描 | 定期运行 `composer audit` 检查 Laravel 及第三方包漏洞 |
| 文件上传安全 | 自定义头像上传已限制图片类型 ✅,但建议增加文件内容校验 |
---
## 二、访问速度优化(🔥高优 / ⚡中优 / 💡低优)
### 🔥 1. 启用 Laravel OPcache
**当前环境:** 通过 Laravel Herd 运行(PHP-FPM),未启用 OPcache。
**影响:** 每个 PHP 请求都要重新编译框架文件,浪费大量 CPU。
**方案(Mac/Linux 生产环境):**
```ini
; php.ini
opcache.enable=1
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
opcache.revalidate_freq=0
opcache.validate_timestamps=0 ; 生产环境关闭文件修改检查
```
---
### 🔥 2. 数据库查询优化
**当前状况:**
- 使用 Redis 缓存,但部分页面可能直接查询 MySQL
- 存在多个游戏(百家乐、赛马、彩票、五子棋等),每次查询都走数据库
**优化方案:**
| 措施 | 说明 | 优先级 |
|------|------|--------|
| 排行榜缓存 | `->remember(60)` 缓存排行榜结果 60 秒 | 🔥 |
| 在线人数缓存 | 当前使用 Redis 实时维护 ✅,保持现状 | - |
| 房间列表缓存 | `Room::all()` 结果缓存到 Redis | 🔥 |
| 游戏配置缓存 | `GameConfig::isEnabled()` 结果缓存 10 秒 | ⚡ |
| 慢查询日志 | 启用 MySQL slow_query_log,定位慢 SQL | ⚡ |
---
### 🔥 3. 静态资源 CDN 加速
**当前:** 所有静态资源(CSS、JS、图片)直接从源服务器加载。
**方案:**
| 资源类型 | 方案 |
|----------|------|
| Vite 构建产物(CSS/JS | 上传到 CDN(阿里云 OSS+CDN / CloudFlare R2 |
| 头像图片 | 启用单独域名或 CDN,添加长期缓存头 |
| 聊天背景图 | 使用 CDN 分发 12 张背景图 |
| Reverb WS 连接 | 通过 CDN/反向代理(如 Nginx)代理 WSS 连接 |
**当前已做:** `.htaccess` 中已对 Vite 构建产物设置 31536000 秒缓存 ✅
**改进:** 将 `public/build/` 下的资源部署到 CDN。
---
### 🔥 4. Reverb WebSocket 优化
**当前:** Reverb 单节点运行,HTTP 协议,端口 8080。
**优化方案:**
| 措施 | 说明 |
|------|------|
| 升级为 WSS | 启用 TLS,避免被运营商劫持/限速 |
| 水平扩展 | 启用 Reverb Scaling(通过 Redis 发布订阅,见 `reverb.php` 配置) |
| Nginx 反向代理 | 用 Nginx 代理 WSS,可同时处理 HTTP 静态资源 |
| 心跳优化 | 当前 `ping_interval=60s`,可考虑适当延长 |
---
### ⚡ 5. Laravel 应用层优化
| 措施 | 说明 | 优先级 |
|------|------|--------|
| 路由缓存 | `php artisan route:cache` — 减少路由注册开销 | ⚡ |
| 配置缓存 | `php artisan config:cache` — 减少 config 加载 | ⚡ |
| 事件缓存 | `php artisan event:cache` — L12 原生支持 | ⚡ |
| 视图缓存 | `php artisan view:cache` — Blade 编译缓存 | ⚡ |
| 模型预加载 | 检查 N+1 查询,使用 `->with()` | ⚡ |
**⚠️ 注意:** `php artisan optimize` 已在 Laravel 12 中被移除,应单独执行以上四个命令。
---
### ⚡ 6. Redis 优化
**当前:** 单机单实例 Redis,承载 Session、Cache、Queue、Reverb Scaling 全部功能。
**建议:**
- 生产环境建议至少 2 个 Redis 实例:一个用于 Session/Cache(可随时清),一个用于 Queue(需持久化)
- Reverb Scaling 发布订阅建议单独连接
- 为 Redis 设置 `maxmemory``maxmemory-policy allkeys-lru` 防止内存溢出
---
### ⚡ 7. 前端加载优化
| 措施 | 说明 |
|------|------|
| JS 代码分割 | Vite 动态 import 拆分大 JS 文件 |
| 懒加载 | 游戏模块(百家乐、赛马等)按需加载 |
| 图片懒加载 | 头像、礼物图片使用 `loading="lazy"` |
| Alpine.js 轻量化 | 当前已使用 Alpine.js ✅ 但避免过多 watcher |
---
### 💡 8. 考虑 Laravel Octane(长期规划)
**说明:** Laravel OctaneSwoole / RoadRunner)将应用常驻内存,消除框架启动开销,可带来 10-30 倍并发性能提升。
**条件:** 需要确保代码无静态变量状态污染,适合用户量增长后的升级。
---
## 三、实施优先级建议
### 第一阶段(紧急 · 1-2 天)🔴🔥
| # | 任务 | 预估工时 |
|---|------|---------|
| 1 | 关闭 `APP_DEBUG` | 5 分钟 |
| 2 | 启用 `SESSION_ENCRYPT` | 5 分钟 |
| 3 | 限制 Reverb `allowed_origins` | 10 分钟 |
| 4 | 配置 Route/Config/Event/View 缓存 | 30 分钟 |
| 5 | 排行榜、房间列表等 Redis 缓存 | 1 小时 |
### 第二阶段(重要 · 3-5 天)🟡⚡
| # | 任务 | 预估工时 |
|---|------|---------|
| 6 | 敏感字段移出 `$fillable` | 1 小时 |
| 7 | 登录失败锁定 + 注册频率限制 | 2 小时 |
| 8 | 数据库慢查询分析与索引优化 | 2 小时 |
| 9 | 前端 JS 懒加载与代码分割 | 3 小时 |
| 10 | OPcache 配置 | 30 分钟 |
### 第三阶段(完善 · 1-2 周)🟢💡
| # | 任务 | 预估工时 |
|---|------|---------|
| 11 | Reverb WSS + Nginx 反向代理 | 2 小时 |
| 12 | 管理员 2FA 验证 | 4 小时 |
| 13 | CDN 部署静态资源 | 1 天 |
| 14 | Content-Security-Policy 等安全头 | 1 小时 |
| 15 | 生产环境 Redis 分实例部署 | 2 小时 |
| 16 | 评估 Laravel Octane 迁移 | 2-3 天 |
---
## 四、检查清单工具
部署到生产环境前可使用以下命令快速检查:
```bash
# Laravel 安全检查
php artisan about # 查看环境配置
php artisan route:list # 查看所有路由(确认无暴露的管理路径)
# Composer 安全审计
composer audit
# 缓存优化
php artisan config:cache
php artisan route:cache
php artisan event:cache
php artisan view:cache
# 依赖更新
composer update --no-dev -o
```
---
> **总结:** 该项目整体架构设计良好(Redis + Reverb 实时通信 + Alpine.js 轻量前端),
> 主要安全短板集中在**生产环境配置**(DEBUG 未关、Session 未加密、WebSocket 无 TLS)和**部分敏感字段保护**。
> 速度优化则聚焦于**缓存策略**和**CDN 静态资源分发**。
>
> 建议从第一阶段紧急问题入手,逐步推进到第二阶段。需要我帮你实施其中任何一部分,随时说!
@@ -48,6 +48,38 @@ class FeedbackController extends Controller
return view('feedback.index', compact('feedbacks', 'myVotedIds'));
}
/**
* 获取反馈第一页数据(JSON API
* 供聊天室模态弹窗使用,格式与 loadMore 一致
*
* @param Request $request type 筛选参数
*/
public function data(Request $request): JsonResponse
{
$type = $request->input('type'); // bug|suggestion|null(全部)
$query = FeedbackItem::with(['replies'])
->orderByDesc('votes_count')
->orderByDesc('created_at');
if ($type && in_array($type, ['bug', 'suggestion'])) {
$query->ofType($type);
}
$items = $query->limit(self::PAGE_SIZE)->get();
$myVotedIds = FeedbackVote::where('user_id', Auth::id())
->whereIn('feedback_id', $items->pluck('id'))
->pluck('feedback_id')
->toArray();
return response()->json([
'items' => $this->formatItems($items, $myVotedIds),
'last_id' => $items->last()?->id ?? 0,
'has_more' => $items->count() === self::PAGE_SIZE,
]);
}
/**
* 懒加载更多反馈(JSON API
* 支持按类型筛选(bug / suggestion
@@ -257,6 +289,10 @@ class FeedbackController extends Controller
*/
private function formatItem(FeedbackItem $item, bool $voted): array
{
/** @var \App\Models\User $user */
$user = Auth::user();
$isOwner = $item->user_id === $user->id;
return [
'id' => $item->id,
'type' => $item->type,
@@ -272,6 +308,8 @@ class FeedbackController extends Controller
'username' => $item->username,
'created_at' => $item->created_at->diffForHumans(),
'voted' => $voted,
'is_owner' => $isOwner,
'can_delete' => ($isOwner && $item->is_within_24_hours) || $user->id === 1,
'replies' => ($item->relationLoaded('replies') ? $item->replies : collect())->map(fn ($r) => [
'id' => $r->id,
'username' => $r->username,
@@ -15,6 +15,7 @@ use App\Http\Requests\StoreGuestbookRequest;
use App\Models\Guestbook;
use App\Models\User;
use App\Services\MessageFilterService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -128,4 +129,69 @@ class GuestbookController extends Controller
return back()->with('success', '该行留言已被抹除。');
}
/**
* 返回留言列表 JSON(供聊天室模态弹窗 AJAX 使用)
*/
public function data(Request $request): JsonResponse
{
$tab = $request->input('tab', 'public');
$page = (int) $request->input('page', 1);
$user = Auth::user();
$query = Guestbook::query()->orderByDesc('id');
if ($tab === 'inbox') {
$query->where('towho', $user->username);
} elseif ($tab === 'outbox') {
$query->where('who', $user->username);
} else {
$query->where(function ($q) use ($user) {
$q->where('secret', 0)
->orWhere('who', $user->username)
->orWhere('towho', $user->username);
});
}
$perPage = 15;
$total = $query->count();
$messages = $query->skip(($page - 1) * $perPage)->take($perPage)->get();
$items = $messages->map(function ($msg) use ($user) {
$isSecret = (bool) $msg->secret;
$isToMe = $msg->towho === $user->username;
$isFromMe = $msg->who === $user->username;
$canDelete = $isFromMe || $isToMe || $user->user_level >= 15;
return [
'id' => $msg->id,
'who' => $msg->who,
'towho' => $msg->towho ?: '',
'secret' => $isSecret,
'text_body' => $msg->text_body,
'post_time' => $msg->post_time?->diffForHumans() ?? '',
'timestamp' => $msg->post_time?->toIso8601String() ?? '',
'is_to_me' => $isToMe,
'is_from_me' => $isFromMe,
'can_delete' => $canDelete,
'who_avatar' => mb_substr($msg->who, 0, 1),
];
});
// 获取所有用户名列表(供发信选择器使用)
$users = User::where('username', '!=', $user->username)
->orderBy('username')
->pluck('username');
return response()->json([
'ok' => true,
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'has_more' => ($page * $perPage) < $total,
'users' => $users,
'tab' => $tab,
]);
}
}
+958
View File
@@ -0,0 +1,958 @@
/**
* 文件功能聊天室主界面样式表
* 复刻原版海军蓝聊天室色系 (CHAT.CSS)
* 包含布局消息窗格输入工具栏用户列表弹窗等所有聊天界面样式
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
/* Alpine.js x-cloak:初始化完成前完全隐藏,防止弹窗闪烁 */
[x-cloak] {
display: none !important;
}
/*
原版海军蓝聊天室色系 (CHAT.CSS 复刻)
*/
:root {
--bg-main: #EAF2FF;
--bg-bar: #b0d8ff;
--bg-header: #4488aa;
--bg-toolbar: #5599aa;
--text-link: #336699;
--text-navy: #112233;
--text-white: #ffffff;
--border-blue: #336699;
--scrollbar-base: #b0d8ff;
--scrollbar-arrow: #336699;
--scrollbar-track: #EAF2FF;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Microsoft YaHei", "SimSun", "宋体", sans-serif;
font-size: 10pt;
background: var(--bg-main);
color: var(--text-navy);
overflow: hidden;
height: 100vh;
}
/* 全局链接样式 */
a {
color: var(--text-link);
text-decoration: none;
}
a:hover {
color: #009900;
}
/* 自定义滚动条 - 模拟原版 */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-base);
border-radius: 2px;
border: 1px solid var(--scrollbar-arrow);
}
::-webkit-scrollbar-track {
background-color: var(--scrollbar-track);
}
::-webkit-scrollbar-corner {
background-color: var(--scrollbar-base);
}
/* ── 主布局 ─────────────────────────────────────── */
.chat-layout {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
/* 防止整体滚动 */
}
/* 左侧主区域 */
.chat-left {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
/* 约束 Flex 子项高度,防止长历史记录撑破 100vh 导致输入栏被挤出屏幕 */
}
/* 竖向工具条 */
.chat-toolbar {
width: 28px;
background: var(--bg-toolbar);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 58px;
/* 与标题栏+公告栏底部对齐 */
padding-bottom: 75px;
/* 与输入栏顶部对齐 */
border-left: 1px solid var(--border-blue);
border-right: 1px solid var(--border-blue);
flex-shrink: 0;
}
.chat-toolbar .tool-btn {
writing-mode: vertical-rl;
text-orientation: upright;
font-size: 11px;
color: #ddd;
cursor: pointer;
padding: 4px 0;
text-align: center;
transition: all 0.15s;
width: 100%;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
letter-spacing: 2px;
}
.chat-toolbar .tool-btn:hover {
color: #fff;
background: #5599cc;
}
/* 右侧用户列表面板 */
.chat-right {
width: 175px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--border-blue);
background: var(--bg-main);
flex-shrink: 0;
}
/* ── 标题栏 ─────────────────────────────────────── */
.room-title-bar {
background: var(--bg-header);
color: var(--text-white);
text-align: center;
padding: 6px 10px;
font-size: 12px;
line-height: 1.6;
flex-shrink: 0;
border-bottom: 1px solid var(--border-blue);
}
.room-title-bar .room-name {
font-weight: bold;
font-size: 14px;
}
.room-title-bar .room-desc {
color: #cee8ff;
font-size: 11px;
}
/* 公告/祝福语滚动条(复刻原版 marquee) */
.room-announcement-bar {
background: linear-gradient(to right, #a8d8a8, #c8f0c8, #a8d8a8);
padding: 2px 6px;
border-bottom: 1px solid var(--border-blue);
flex-shrink: 0;
height: 20px;
line-height: 20px;
overflow: hidden;
}
/* ── 消息窗格 ───────────────────────────────────── */
.message-panes {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.message-pane {
flex: 1;
overflow-y: auto;
padding: 5px 8px;
background: var(--bg-main);
font-size: 10pt;
line-height: 170%;
min-height: 0;
}
.message-pane.say1 {
border-bottom: 2px solid var(--border-blue);
}
.message-pane.say2 {
display: block;
flex: 0.4;
border-top: 2px solid var(--border-blue);
}
.message-pane.say1 {
flex: 0.6;
}
/* 消息行样式 */
.msg-line {
margin: 2px 0;
word-wrap: break-word;
word-break: break-all;
}
.msg-line .msg-time {
font-size: 9px;
color: #999;
}
.msg-line .msg-user {
cursor: pointer;
font-weight: bold;
}
.msg-line .msg-user:hover {
text-decoration: underline;
}
.msg-line .msg-action {
color: #009900;
}
.msg-line .msg-secret {
color: #cc00cc;
font-style: italic;
}
.msg-line .msg-content {
margin-left: 4px;
}
.msg-line.sys-msg {
color: #cc0000;
text-align: center;
font-size: 9pt;
}
/* ── 底部输入工具栏 ─────────────────────────────── */
.input-bar {
height: auto;
min-height: 75px;
background: var(--bg-bar);
border-top: 2px solid var(--border-blue);
padding: 3px 6px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
/* 欢迎语下拉浮层 */
.welcome-menu {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
z-index: 9000;
background: #fff;
border: 1px solid #b0c8e0;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
min-width: 280px;
max-width: 360px;
padding: 4px 0;
}
.welcome-menu-item {
padding: 6px 12px;
font-size: 12px;
color: #224466;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid #eef4fb;
transition: background 0.1s;
}
.welcome-menu-item:last-child {
border-bottom: none;
}
.welcome-menu-item:hover {
background: #ddeeff;
color: #003366;
}
.input-bar select,
.input-bar input[type="text"],
.input-bar input[type="checkbox"] {
font-size: 12px;
border: 1px solid navy;
color: #224466;
padding: 1px 2px;
background: #fff;
}
.input-bar select {
max-width: 100px;
}
.input-bar .input-row {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.input-bar .say-input {
flex: 1;
min-width: 200px;
font-size: 12px;
border: 1px solid navy;
color: #224466;
padding: 3px 5px;
outline: none;
}
.input-bar .say-input:focus {
border-color: #0066cc;
box-shadow: 0 0 3px rgba(0, 102, 204, 0.3);
}
.input-bar .send-btn {
background: var(--bg-header);
color: white;
border: 1px solid var(--border-blue);
padding: 3px 12px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
transition: background 0.15s;
}
.input-bar .send-btn:hover {
background: #336699;
}
.input-bar .send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.input-bar label {
font-size: 12px;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
gap: 2px;
}
/* ── 右侧用户列表 Tab 栏 ──────────────────────── */
.right-tabs {
display: flex;
background: var(--bg-bar);
border-bottom: 2px solid navy;
flex-shrink: 0;
}
.right-tabs .tab-btn {
flex: 1;
text-align: center;
font-size: 12px;
padding: 3px 0;
cursor: pointer;
color: navy;
background: var(--bg-bar);
border: none;
font-family: inherit;
transition: all 0.15s;
user-select: none;
}
.right-tabs .tab-btn.active {
background: navy;
color: white;
}
.right-tabs .tab-btn:hover:not(.active) {
background: #99c8ee;
}
/* 用户列表内容 */
.user-list-content {
flex: 1;
overflow-y: auto;
padding: 4px;
}
.user-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 4px;
cursor: pointer;
font-size: 12px;
border-bottom: 1px dotted #cde;
transition: background 0.15s;
}
.user-item:hover {
background: #d0e8ff;
}
.user-item .user-head {
width: 36px;
height: 36px;
border-radius: 2px;
object-fit: cover;
background: transparent;
mix-blend-mode: multiply;
flex-shrink: 0;
}
.user-item .user-name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-link);
font-weight: bold;
}
.user-item .user-badge-slot {
flex-shrink: 0;
max-width: 78px;
min-width: 0;
overflow: hidden;
}
.user-item .user-sex {
font-size: 10px;
}
.user-item .user-level {
font-size: 9px;
color: #999;
}
.user-badge-icon {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.chat-hover-tooltip {
position: fixed;
z-index: 10030;
max-width: min(220px, calc(100vw - 20px));
padding: 4px 8px;
border: 1px solid #244d77;
border-radius: 4px;
background: linear-gradient(to bottom, #fffef2, #fff6c9);
color: #243b53;
font-size: 11px;
line-height: 1.35;
white-space: nowrap;
box-shadow: 0 6px 18px rgba(17, 34, 51, 0.24);
pointer-events: none;
}
.chat-hover-tooltip::after {
content: '';
position: absolute;
top: 50%;
width: 6px;
height: 6px;
background: #fff9d8;
transform: translateY(-50%) rotate(45deg);
}
.chat-hover-tooltip[data-side="right"]::after {
left: -4px;
border-left: 1px solid #244d77;
border-bottom: 1px solid #244d77;
}
.chat-hover-tooltip[data-side="left"]::after {
right: -4px;
border-top: 1px solid #244d77;
border-right: 1px solid #244d77;
}
/* 在线人数统计 */
.online-stats {
background: var(--bg-bar);
text-align: center;
font-size: 11px;
padding: 3px;
border-top: 1px solid var(--border-blue);
color: navy;
flex-shrink: 0;
}
/* ── 用户名片弹窗 ───────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-card {
background: white;
border: 2px solid var(--border-blue);
border-radius: 8px;
width: 420px;
max-width: 90vw;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.modal-header {
background: linear-gradient(135deg, var(--bg-header), var(--border-blue));
color: white;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
font-size: 16px;
font-weight: bold;
}
.modal-close {
background: none;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
opacity: 0.8;
}
.modal-close:hover {
opacity: 1;
}
.modal-body {
padding: 16px;
}
.modal-body .profile-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.modal-body .profile-avatar {
width: 56px;
height: 56px;
border-radius: 6px;
object-fit: cover;
border: 2px solid var(--bg-bar);
background: #eee;
}
.modal-body .profile-info h4 {
font-size: 16px;
font-weight: bold;
color: var(--text-navy);
}
.modal-body .profile-info .level-badge {
display: inline-block;
background: var(--bg-bar);
color: var(--border-blue);
font-size: 11px;
padding: 1px 6px;
border-radius: 10px;
font-weight: bold;
margin-left: 4px;
}
.modal-body .profile-info .sex-badge {
font-size: 11px;
margin-left: 2px;
}
.modal-body .profile-detail {
background: #f5f9ff;
border: 1px solid #e0ecff;
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
color: #666;
margin-bottom: 12px;
}
.modal-actions {
padding: 0 16px 16px;
display: flex;
gap: 8px;
}
.modal-actions button,
.modal-actions a {
flex: 1;
text-align: center;
padding: 6px 0;
border-radius: 6px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
border: 1px solid;
transition: all 0.15s;
text-decoration: none;
display: block;
}
.btn-kick {
background: #fee;
color: #c00;
border-color: #fcc;
}
.btn-kick:hover {
background: #fcc;
}
.btn-mute {
background: #fff8e6;
color: #b86e00;
border-color: #ffe0a0;
}
.btn-mute:hover {
background: #ffe0a0;
}
.btn-mail {
background: #f0e8ff;
color: #6633cc;
border-color: #d8c8ff;
}
.btn-mail:hover {
background: #d8c8ff;
}
.btn-whisper {
background: #e8f5ff;
color: var(--text-link);
border-color: var(--bg-bar);
}
.btn-whisper:hover {
background: var(--bg-bar);
}
/* 禁言输入行 */
.mute-form {
display: flex;
align-items: center;
gap: 6px;
padding: 0 16px 12px;
}
.mute-form input {
width: 60px;
border: 1px solid #ffe0a0;
border-radius: 4px;
padding: 3px 6px;
font-size: 12px;
}
.mute-form button {
background: #b86e00;
color: white;
border: none;
border-radius: 4px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
}
/* ── 头像选择器 ─────────────────────────────────── */
.avatar-option {
width: 36px;
height: 36px;
cursor: pointer;
border: 2px solid transparent;
border-radius: 3px;
transition: border-color 0.15s, transform 0.15s;
}
.avatar-option:hover {
border-color: #88bbdd;
transform: scale(1.15);
}
.avatar-option.selected {
border-color: #336699;
box-shadow: 0 0 6px rgba(51, 102, 153, 0.5);
}
/* 送花礼物弹跳动画 */
@keyframes giftBounce {
0% {
transform: scale(0.3) rotate(-15deg);
opacity: 0;
}
50% {
transform: scale(1.3) rotate(5deg);
opacity: 1;
}
70% {
transform: scale(0.9) rotate(-3deg);
}
100% {
transform: scale(1) rotate(0deg);
}
}
/*
手机端响应式布局 640px
*/
@media (max-width: 640px) {
/* 隐藏中间工具条和右侧面板,让消息区占满全宽 */
.chat-toolbar,
.chat-right {
display: none !important;
}
/* 输入框最小宽度适配手机屏 */
.input-bar .say-input {
min-width: 0;
}
/* ── 右下角浮动按钮组 ── */
#mobile-fabs {
position: fixed;
top: 35%;
right: 10px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 500;
}
.mobile-fab {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid rgba(51, 102, 153, 0.4);
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: #336699;
font-size: 18px;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s, background 0.15s, box-shadow 0.15s;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.mobile-fab:active {
transform: scale(0.92);
background: rgba(51, 102, 153, 0.15);
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
}
/* ── 抽屉遮罩 ── */
#mobile-drawer-mask {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 600;
}
#mobile-drawer-mask.open {
display: block;
}
/* ── 抽屉通用容器(顶部向下滑入) ── */
.mobile-drawer {
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 700;
background: #fff;
border-bottom: 2px solid var(--border-blue);
border-radius: 0 0 14px 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
transform: translateY(-100%);
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
display: flex;
flex-direction: column;
}
.mobile-drawer.open {
transform: translateY(0);
}
/* 抽屉顶部标题栏 */
.mobile-drawer-header {
background: var(--bg-header);
color: #fff;
padding: 10px 14px;
font-size: 14px;
font-weight: bold;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.mobile-drawer-close {
background: none;
border: none;
color: #fff;
font-size: 20px;
cursor: pointer;
line-height: 1;
opacity: 0.85;
padding: 0 4px;
}
/* ── 工具条抽屉:横向网格排列按钮 ── */
#mobile-drawer-toolbar .mobile-drawer-body {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 1px;
background: var(--border-blue);
max-height: 55vh;
overflow-y: auto;
}
#mobile-drawer-toolbar .mobile-tool-btn {
background: var(--bg-toolbar);
color: #ddd;
font-size: 13px;
padding: 14px 4px;
text-align: center;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s, color 0.15s;
writing-mode: horizontal-tb;
letter-spacing: 0;
}
#mobile-drawer-toolbar .mobile-tool-btn:active {
background: #5599cc;
color: #fff;
}
/* ── 名单/房间抽屉:限定高度,内部 flex 子项才能正确贡献高度 ── */
#mobile-drawer-users {
height: 80vh;
}
#mobile-drawer-users .mobile-right-clone {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
/* 抽屉内的 tabs 复用样式 */
#mobile-drawer-users .right-tabs {
flex-shrink: 0;
}
#mobile-drawer-users .user-list-content {
flex: 1;
overflow-y: auto;
min-height: 0;
-webkit-overflow-scrolling: touch;
}
#mobile-drawer-users .online-stats {
flex-shrink: 0;
}
/* ── 名单抽屉内用户列表 item 放大触摸区域 ── */
#mob-online-users-list .user-item {
padding: 7px 8px;
font-size: 13px;
}
/* ── 现有弹窗(settings / avatar / shop)手机端自适应 ── */
/* 防止固定像素宽度的 modal 内容区超出手机屏宽 */
#settings-modal > div,
#avatar-picker-modal > div {
width: 92vw !important;
max-width: 420px !important;
max-height: 85vh !important;
}
#shop-modal {
padding: 12px 6px !important;
}
#shop-modal-inner {
width: 92vw !important;
max-width: 420px !important;
max-height: 85vh !important;
overflow-y: auto !important;
}
/* ── 手机端隐藏房间介绍 ── */
.room-desc {
display: none !important;
}
/* ── 手机端隐藏输入栏中不常用的控件(动作/字色/字号/禁音/分屏) ── */
/* 用 :has() 选择器精准定位含对应 input/select 的 label */
.input-row label:has(#action),
.input-row label:has(#font_color),
.input-row label:has(#font_size_select),
.input-row label:has(#sound_muted) {
display: none !important;
}
}
/* 桌面端(> 640px)不显示浮动按钮和抽屉组件 */
@media (min-width: 641px) {
#mobile-fabs,
#mobile-drawer-mask,
#mobile-drawer-toolbar,
#mobile-drawer-users {
display: none !important;
}
}
+472 -684
View File
File diff suppressed because it is too large Load Diff
+625
View File
@@ -0,0 +1,625 @@
/**
* 用户反馈模态弹窗模块
* 供聊天室工具栏"反馈"按钮使用,AJAX 加载反馈列表,内嵌提交表单。
*/
let feedbackBound = false;
let fbCurrentTab = 'all';
let fbLastId = null;
let fbHasMore = true;
let fbLoading = false;
// ── DOM 缓存 ──
let $modal, $inner, $list, $tabs, $toast, $writeBtn, $writeOverlay, $form, $type, $title, $content;
let $closeBtn, $writeClose, $formCancel, $loader;
function cacheDom() {
$modal = document.getElementById('feedback-modal');
if (!$modal) return false;
$inner = document.getElementById('feedback-modal-inner');
$list = document.getElementById('feedback-list');
$tabs = document.querySelectorAll('.feedback-tab');
$toast = document.getElementById('feedback-toast');
$writeBtn = document.getElementById('feedback-submit-btn');
$writeOverlay = document.getElementById('feedback-write-overlay');
$form = document.getElementById('feedback-form');
$type = document.getElementById('fb-type');
$title = document.getElementById('fb-title');
$content = document.getElementById('fb-content');
$closeBtn = document.querySelector('[data-feedback-modal-close]');
$writeClose = document.querySelector('[data-feedback-write-close]');
$formCancel = document.querySelector('[data-fb-form-cancel]');
$loader = document.getElementById('feedback-loader');
return true;
}
function getDataUrl() {
return $modal?.getAttribute('data-feedback-data-url') || '/feedback/data';
}
function getMoreUrl() {
return $modal?.getAttribute('data-feedback-more-url') || '/feedback/more';
}
function getStoreUrl() {
return $modal?.getAttribute('data-feedback-store-url') || '/feedback';
}
function getVoteUrl(id) {
const tmpl = $modal?.getAttribute('data-feedback-vote-url-template') || '/feedback/__ID__/vote';
return tmpl.replace('__ID__', id);
}
function getReplyUrl(id) {
const tmpl = $modal?.getAttribute('data-feedback-reply-url-template') || '/feedback/__ID__/reply';
return tmpl.replace('__ID__', id);
}
function getDestroyUrl(id) {
const tmpl = $modal?.getAttribute('data-feedback-destroy-url-template') || '/feedback/__ID__';
return tmpl.replace('__ID__', id);
}
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta?.getAttribute('content') || '';
}
// ── Toast 提示 ──
function showToast(msg, type) {
if (!$toast) return;
$toast.textContent = msg;
$toast.style.display = 'block';
$toast.style.background = type === 'error' ? '#fee2e2' : '#d1fae5';
$toast.style.color = type === 'error' ? '#dc2626' : '#065f46';
$toast.style.border = '1px solid ' + (type === 'error' ? '#fecaca' : '#a7f3d0');
setTimeout(() => { $toast.style.display = 'none'; }, 3000);
}
// ── 打开/关闭 ──
export function openFeedbackModal() {
if (!$modal) { cacheDom(); if (!$modal) return; }
$modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
if (!feedbackBound) {
bindFeedbackControls();
}
loadFeedbackData('all');
}
export function closeFeedbackModal() {
if (!$modal) return;
$modal.style.display = 'none';
document.body.style.overflow = '';
$writeOverlay?.classList.remove('active');
}
// ── 加载反馈数据 ──
export function loadFeedbackData(tab) {
tab = tab || fbCurrentTab;
fbCurrentTab = tab;
fbLastId = null;
fbHasMore = true;
fbLoading = false;
if (!$list) return;
// 更新 Tab 高亮
$tabs.forEach(btn => {
const t = btn.getAttribute('data-feedback-tab');
btn.classList.toggle('active', t === tab);
});
$list.innerHTML = '<div class="fb-loading">加载中…</div>';
$loader?.classList.add('hidden');
let url = getDataUrl();
if (tab !== 'all') {
url += '?type=' + encodeURIComponent(tab);
}
fetch(url, { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(res => {
const items = res.items || [];
fbLastId = res.last_id || (items.length > 0 ? items[items.length - 1].id : null);
fbHasMore = res.has_more === true;
if (items.length === 0) {
$list.innerHTML = '<div class="fb-empty"><span class="fb-empty-icon">💬</span>暂无反馈</div>';
$loader?.classList.add('hidden');
return;
}
renderFeedbackList(items);
updateLoader();
})
.catch(() => {
$list.innerHTML = '<div class="fb-empty"><span class="fb-empty-icon">😵</span>数据加载失败</div>';
$loader?.classList.add('hidden');
});
}
// ── 加载更多 ──
export function loadMoreFeedback() {
if (fbLoading || !fbHasMore || !fbLastId) return;
fbLoading = true;
$loader?.classList.remove('hidden');
$loader.textContent = '加载中…';
let url = getMoreUrl() + '?after_id=' + fbLastId;
if (fbCurrentTab !== 'all') {
url += '&type=' + encodeURIComponent(fbCurrentTab);
}
fetch(url, { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(res => {
const items = res.items || [];
fbLoading = false;
if (items.length > 0) {
fbLastId = items[items.length - 1].id;
fbHasMore = res.has_more === true;
appendFeedbackList(items);
} else {
fbHasMore = false;
}
updateLoader();
})
.catch(() => {
fbLoading = false;
$loader?.classList.add('hidden');
});
}
// ── 渲染列表 ──
function renderFeedbackList(items) {
let html = '';
items.forEach(item => {
html += buildFeedbackCard(item);
});
$list.innerHTML = html;
}
function appendFeedbackList(items) {
items.forEach(item => {
$list.insertAdjacentHTML('beforeend', buildFeedbackCard(item));
});
}
function buildFeedbackCard(item) {
const typeClass = item.type === 'bug' ? 'bug' : 'suggestion';
const statusClass = 'fb-status-badge';
const statusStyle = getStatusStyle(item.status_color);
let actionsHtml = '';
// 赞同按钮(不能赞同自己的)
const voteIcon = item.voted ? '👍' : '👆';
const voteBtnClass = item.voted ? 'fb-vote-btn voted' : 'fb-vote-btn';
const voteDisabled = item.is_owner ? 'disabled' : '';
actionsHtml += `<button class="${voteBtnClass}" data-fb-vote="${item.id}" ${voteDisabled} title="${item.is_owner ? '不能赞同自己的反馈' : '赞同'}">${voteIcon} <span data-fb-vote-count="${item.id}">${item.votes_count}</span></button>`;
// 删除按钮
if (item.can_delete) {
actionsHtml += `<button class="fb-delete-btn" data-fb-delete="${item.id}">删除</button>`;
}
// 展开评论数
const repliesLabel = item.replies_count > 0 ? `💬 ${item.replies_count}` : '💬 0';
return `<div class="fb-card" data-fb-id="${item.id}">
<div class="fb-card-header">
<span class="fb-type-badge ${typeClass}">${escapeHtml(item.type_label)}</span>
<span class="${statusClass}" style="${statusStyle}">${escapeHtml(item.status_label)}</span>
<span class="fb-reply-count">${repliesLabel}</span>
</div>
<div class="fb-card-title">${escapeHtml(item.title)}</div>
<div class="fb-card-meta">${escapeHtml(item.username)} · ${escapeHtml(item.created_at)}</div>
<div class="fb-card-footer-actions">${actionsHtml}</div>
<div class="fb-detail" style="display:none;">
<div class="fb-detail-content">${nl2br(escapeHtml(item.content))}</div>
${item.admin_remark ? `<div class="fb-admin-remark"><div class="fb-admin-remark-label">🛡️ 开发者官方回复</div>${nl2br(escapeHtml(item.admin_remark))}</div>` : ''}
<div class="fb-replies" data-fb-replies="${item.id}">
${(item.replies || []).map(r => buildReplyHtml(r)).join('')}
</div>
<div class="fb-reply-form">
<textarea data-fb-reply-input="${item.id}" placeholder="补充说明…" maxlength="1000" rows="1"></textarea>
<button data-fb-reply-btn="${item.id}">发送</button>
</div>
</div>
</div>`;
}
function buildReplyHtml(reply) {
const adminClass = reply.is_admin ? 'admin' : '';
const badgeHtml = reply.is_admin ? '<span class="fb-reply-admin-badge">开发者</span>' : '';
return `<div class="fb-reply-item ${adminClass}">
<div class="fb-reply-header">
<span class="fb-reply-username">${escapeHtml(reply.username)}</span>
${badgeHtml}
<span class="fb-reply-time">${escapeHtml(reply.created_at)}</span>
</div>
<div class="fb-reply-body">${nl2br(escapeHtml(reply.content))}</div>
</div>`;
}
function getStatusStyle(color) {
const map = {
gray: 'background:#f3f4f6;color:#4b5563;',
green: 'background:#dcfce7;color:#15803d;',
blue: 'background:#dbeafe;color:#1d4ed8;',
emerald: 'background:#d1fae5;color:#047857;',
red: 'background:#fee2e2;color:#dc2626;',
orange: 'background:#ffedd5;color:#c2410c;',
};
return map[color] || 'background:#f3f4f6;color:#4b5563;';
}
function updateLoader() {
if (!$loader) return;
if (fbHasMore) {
$loader.classList.remove('hidden');
$loader.textContent = fbLoading ? '加载中…' : '↓ 下拉加载更多';
} else {
$loader.classList.add('hidden');
}
}
// ── 提交反馈表单 ──
function openWriteForm() {
if (!$writeOverlay) return;
$writeOverlay.classList.add('active');
if ($title) {
$title.value = '';
setTimeout(() => $title.focus(), 100);
}
if ($content) $content.value = '';
if ($type) $type.value = 'bug';
}
function closeWriteForm() {
if (!$writeOverlay) return;
$writeOverlay.classList.remove('active');
}
function submitFeedbackForm(event) {
event.preventDefault();
if (!$form) return;
const title = ($title?.value || '').trim();
const content = ($content?.value || '').trim();
const type = $type?.value || 'bug';
if (!title) {
showToast('请填写标题', 'error');
return;
}
if (!content) {
showToast('请填写详细描述', 'error');
return;
}
const submitBtn = $form.querySelector('.fb-form-submit');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = '提交中…';
}
fetch(getStoreUrl(), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ type, title, content }),
})
.then(r => r.json())
.then(res => {
if (res.status === 'success') {
showToast('✅ ' + (res.message || '反馈已提交,感谢您的贡献!'), 'success');
closeWriteForm();
loadFeedbackData(fbCurrentTab);
} else {
showToast('❌ ' + (res.message || '提交失败,请重试'), 'error');
}
})
.catch(() => {
showToast('❌ 网络异常,请重试', 'error');
})
.finally(() => {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = '✈️ 提交反馈';
}
});
}
// ── 赞同/取消赞同 ──
function toggleVote(id) {
const card = $list?.querySelector(`[data-fb-id="${id}"]`);
const voteBtn = card?.querySelector('[data-fb-vote]');
if (!voteBtn) return;
// 乐观更新
const wasVoted = voteBtn.classList.contains('voted');
const countSpan = voteBtn.querySelector('[data-fb-vote-count]');
let prevCount = parseInt(countSpan?.textContent || '0');
voteBtn.classList.toggle('voted');
voteBtn.innerHTML = (wasVoted ? '👆' : '👍') + ' <span data-fb-vote-count="' + id + '">' + (wasVoted ? prevCount - 1 : prevCount + 1) + '</span>';
fetch(getVoteUrl(id), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Accept': 'application/json',
},
})
.then(r => r.json())
.then(res => {
if (res.status === 'success') {
voteBtn.innerHTML = (res.voted ? '👍' : '👆') + ' <span data-fb-vote-count="' + id + '">' + res.votes_count + '</span>';
if (res.voted) {
voteBtn.classList.add('voted');
} else {
voteBtn.classList.remove('voted');
}
} else {
// 回滚
voteBtn.classList.toggle('voted');
voteBtn.innerHTML = (wasVoted ? '👍' : '👆') + ' <span data-fb-vote-count="' + id + '">' + prevCount + '</span>';
showToast('❌ ' + (res.message || '操作失败'), 'error');
}
})
.catch(() => {
// 回滚
voteBtn.classList.toggle('voted');
voteBtn.innerHTML = (wasVoted ? '👍' : '👆') + ' <span data-fb-vote-count="' + id + '">' + prevCount + '</span>';
});
}
// ── 提交评论 ──
function submitReply(id) {
const input = $list?.querySelector(`[data-fb-reply-input="${id}"]`);
const btn = $list?.querySelector(`[data-fb-reply-btn="${id}"]`);
if (!input || !btn) return;
const content = input.value.trim();
if (!content) return;
btn.disabled = true;
btn.textContent = '发送中…';
fetch(getReplyUrl(id), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ content }),
})
.then(r => r.json())
.then(res => {
if (res.status === 'success' && res.reply) {
// 追加评论到列表
const repliesContainer = $list?.querySelector(`[data-fb-replies="${id}"]`);
if (repliesContainer) {
repliesContainer.insertAdjacentHTML('beforeend', buildReplyHtml(res.reply));
}
input.value = '';
// 更新评论计数
const card = $list?.querySelector(`[data-fb-id="${id}"]`);
if (card) {
const countEl = card.querySelector('.fb-reply-count');
if (countEl) {
const current = parseInt(countEl.textContent?.replace(/[^0-9]/g, '') || '0');
countEl.textContent = '💬 ' + (current + 1);
}
}
showToast('✅ 评论已提交', 'success');
} else {
showToast('❌ ' + (res.message || '评论失败'), 'error');
}
})
.catch(() => {
showToast('❌ 网络异常', 'error');
})
.finally(() => {
btn.disabled = false;
btn.textContent = '发送';
});
}
// ── 删除反馈(通过 AJAX) ──
function deleteFeedback(id) {
if (!confirm('确定要删除这条反馈吗?')) return;
fetch(getDestroyUrl(id), {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Accept': 'application/json',
},
})
.then(r => {
if (r.ok) {
return r.json();
}
throw new Error('删除失败');
})
.then(res => {
if (res.status === 'success') {
showToast('✅ ' + (res.message || '反馈已删除'), 'success');
const card = $list?.querySelector(`[data-fb-id="${id}"]`);
if (card) card.remove();
// 检查列表是否为空
if ($list && $list.querySelectorAll('.fb-card').length === 0) {
$list.innerHTML = '<div class="fb-empty"><span class="fb-empty-icon">💬</span>暂无反馈</div>';
}
} else {
showToast('❌ ' + (res.message || '删除失败'), 'error');
}
})
.catch(() => {
showToast('❌ 删除失败', 'error');
});
}
// ── 展开/收起详情 ──
function toggleDetail(card) {
if (!card) return;
const detail = card.querySelector('.fb-detail');
if (!detail) return;
const isVisible = detail.style.display !== 'none';
// 收起其他已展开的
$list?.querySelectorAll('.fb-card .fb-detail').forEach(d => {
if (d !== detail) d.style.display = 'none';
});
detail.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
// 自动调整 textarea 高度
const ta = card.querySelector('.fb-reply-form textarea');
if (ta) {
ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px';
}
}
}
// ── 事件绑定 ──
export function bindFeedbackControls() {
if (feedbackBound) return;
if (!cacheDom()) {
setTimeout(() => {
if (cacheDom()) bindFeedbackControls();
}, 500);
return;
}
feedbackBound = true;
// 关闭按钮
$closeBtn?.addEventListener('click', closeFeedbackModal);
// 遮罩层点击关闭
$modal?.addEventListener('click', (e) => {
if (e.target === $modal) closeFeedbackModal();
});
// Tab 切换
$tabs.forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.getAttribute('data-feedback-tab') || 'all';
closeWriteForm();
loadFeedbackData(tab);
});
});
// 提交反馈按钮(底部)
$writeBtn?.addEventListener('click', openWriteForm);
// 写表单关闭/取消
$writeClose?.addEventListener('click', closeWriteForm);
$formCancel?.addEventListener('click', closeWriteForm);
// 点击写表单遮罩关闭
$writeOverlay?.addEventListener('click', (e) => {
if (e.target === $writeOverlay) closeWriteForm();
});
// 表单提交
$form?.addEventListener('submit', submitFeedbackForm);
// ── 事件委托:列表内部交互 ──
$list?.addEventListener('click', (e) => {
// 卡片点击展开/收起(排除按钮)
const card = e.target.closest('.fb-card');
if (card && !e.target.closest('button') && !e.target.closest('textarea')) {
toggleDetail(card);
return;
}
// 赞同按钮
const voteBtn = e.target.closest('[data-fb-vote]');
if (voteBtn) {
const id = voteBtn.getAttribute('data-fb-vote');
if (!voteBtn.disabled) toggleVote(id);
return;
}
// 删除按钮
const deleteBtn = e.target.closest('[data-fb-delete]');
if (deleteBtn) {
const id = deleteBtn.getAttribute('data-fb-delete');
deleteFeedback(id);
return;
}
// 评论发送按钮
const replyBtn = e.target.closest('[data-fb-reply-btn]');
if (replyBtn) {
const id = replyBtn.getAttribute('data-fb-reply-btn');
if (!replyBtn.disabled) submitReply(id);
return;
}
});
// ── 事件委托:列表滚动加载更多 ──
$list?.addEventListener('scroll', () => {
if (!$list) return;
const { scrollTop, scrollHeight, clientHeight } = $list;
if (scrollTop + clientHeight >= scrollHeight - 60) {
loadMoreFeedback();
}
});
// ── 事件委托:评论输入框自动调整高度 ──
$list?.addEventListener('input', (e) => {
const ta = e.target.closest('[data-fb-reply-input]');
if (ta) {
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 80) + 'px';
}
});
// ── 事件委托:评论输入框 Enter 发送 ──
$list?.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
const ta = e.target.closest('[data-fb-reply-input]');
if (ta) {
e.preventDefault();
const id = ta.getAttribute('data-fb-reply-input');
submitReply(id);
}
}
});
// ESC 关闭
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if ($writeOverlay?.classList.contains('active')) {
closeWriteForm();
} else if ($modal?.style.display === 'flex') {
closeFeedbackModal();
}
}
});
}
// ── 工具函数 ──
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function nl2br(str) {
if (!str) return '';
return str.replace(/\n/g, '<br>');
}
+392
View File
@@ -0,0 +1,392 @@
/**
* 星光留言板模态弹窗模块
* 供聊天室工具栏"留言"按钮使用,AJAX 加载留言数据,内嵌写留言表单。
*/
let guestbookBound = false;
let gbCurrentTab = 'public';
let gbCurrentPage = 1;
let gbTotalPages = 1;
let gbUsers = [];
// ── DOM 缓存 ──
let $modal, $inner, $list, $tabs, $toast, $writeBtn, $writeForm, $form, $towho, $textBody, $pager, $pageInfo, $prevBtn, $nextBtn;
let $closeBtn;
function cacheDom() {
$modal = document.getElementById('guestbook-modal');
if (!$modal) return false;
$inner = document.getElementById('guestbook-modal-inner');
$list = document.getElementById('guestbook-list');
$tabs = document.querySelectorAll('.guestbook-tab');
$toast = document.getElementById('guestbook-toast');
$writeBtn = document.getElementById('guestbook-write-btn');
$writeForm = document.getElementById('guestbook-write-form');
$form = document.getElementById('guestbook-form');
$towho = document.getElementById('gb-towho');
$textBody = document.getElementById('gb-text-body');
$pager = document.getElementById('guestbook-pager');
$pageInfo = document.getElementById('gb-page-info');
$prevBtn = document.getElementById('gb-page-prev');
$nextBtn = document.getElementById('gb-page-next');
$closeBtn = document.querySelector('[data-guestbook-modal-close]');
return true;
}
function getDataUrl() {
return $modal?.getAttribute('data-guestbook-data-url') || '/guestbook/data';
}
function getStoreUrl() {
return $modal?.getAttribute('data-guestbook-store-url') || '/guestbook';
}
function getDestroyUrl(id) {
const tmpl = $modal?.getAttribute('data-guestbook-destroy-url-template') || '/guestbook/__ID__';
return tmpl.replace('__ID__', id);
}
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta?.getAttribute('content') || '';
}
// ── Toast 提示 ──
function showToast(msg, type) {
if (!$toast) return;
$toast.textContent = msg;
$toast.style.display = 'block';
$toast.style.background = type === 'error' ? '#fee2e2' : '#d1fae5';
$toast.style.color = type === 'error' ? '#dc2626' : '#065f46';
$toast.style.border = '1px solid ' + (type === 'error' ? '#fecaca' : '#a7f3d0');
setTimeout(() => { $toast.style.display = 'none'; }, 3000);
}
// ── 打开/关闭 ──
export function openGuestbookModal() {
if (!$modal) { cacheDom(); if (!$modal) return; }
$modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
if (!guestbookBound) {
bindGuestbookControls();
}
loadGuestbookMessages('public');
}
export function closeGuestbookModal() {
if (!$modal) return;
$modal.style.display = 'none';
document.body.style.overflow = '';
$writeForm.classList.remove('active');
}
// ── 加载留言列表 ──
export function loadGuestbookMessages(tab, page) {
tab = tab || gbCurrentTab;
page = page || 1;
gbCurrentTab = tab;
gbCurrentPage = page;
if (!$list) return;
// 更新 Tab 高亮
$tabs.forEach(btn => {
const t = btn.getAttribute('data-guestbook-tab');
btn.classList.toggle('active', t === tab);
});
$list.innerHTML = '<div class="gb-loading">加载中…</div>';
$pager.style.display = 'none';
const url = getDataUrl() + '?tab=' + encodeURIComponent(tab) + '&page=' + page;
fetch(url, { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(res => {
if (!res.ok) {
$list.innerHTML = '<div class="gb-empty"><span class="gb-empty-icon">😵</span>数据加载失败</div>';
return;
}
gbUsers = res.users || [];
const total = res.total || 0;
const perPage = res.per_page || 15;
const totalPages = Math.ceil(total / perPage) || 1;
gbTotalPages = totalPages;
if (res.items.length === 0) {
const emptyMsg = tab === 'inbox' ? '暂无收件' : tab === 'outbox' ? '暂无发件' : '暂无公共留言';
$list.innerHTML = '<div class="gb-empty"><span class="gb-empty-icon">📭</span>' + emptyMsg + '</div>';
$pager.style.display = 'none';
return;
}
let html = '';
res.items.forEach(item => {
const isSecret = item.secret;
const cardClass = isSecret ? 'gb-card gb-secret' : 'gb-card';
const avatarClass = isSecret ? 'gb-avatar secret' : 'gb-avatar';
const towhoClass = item.towho ? 'gb-towho' : 'gb-towho public';
const towhoLabel = item.towho || '大家';
let actionsHtml = '';
if (!item.is_from_me) {
actionsHtml += '<button class="gb-btn gb-btn-reply" data-gb-reply="' + escapeAttr(item.who) + '">回复TA</button>';
}
if (item.can_delete) {
actionsHtml += '<button class="gb-btn gb-btn-delete" data-gb-delete="' + item.id + '">删除</button>';
}
html += '<div class="' + cardClass + '" data-gb-id="' + item.id + '">'
+ '<div class="gb-card-header">'
+ '<div class="gb-card-author">'
+ '<span class="' + avatarClass + '">' + escapeHtml(item.who_avatar) + '</span>'
+ '<span class="gb-who">' + escapeHtml(item.who) + '</span>'
+ '<span class="gb-arrow">→</span>'
+ '<span class="' + towhoClass + '">' + escapeHtml(towhoLabel) + '</span>'
+ (isSecret ? '<span class="gb-secret-badge">🔒 悄悄话</span>' : '')
+ '</div>'
+ '<span class="gb-time">' + escapeHtml(item.post_time) + '</span>'
+ '</div>'
+ '<div class="gb-body">' + nl2br(escapeHtml(item.text_body)) + '</div>'
+ '<div class="gb-card-footer">' + actionsHtml + '</div>'
+ '</div>';
});
$list.innerHTML = html;
// 分页
if (totalPages > 1) {
$pager.style.display = 'flex';
$pageInfo.textContent = '第 ' + page + ' / ' + totalPages + ' 页';
$prevBtn.disabled = page <= 1;
$nextBtn.disabled = page >= totalPages;
} else {
$pager.style.display = 'none';
}
// 更新用户列表(写表单的收件人下拉)
updateUserSelect();
})
.catch(() => {
$list.innerHTML = '<div class="gb-empty"><span class="gb-empty-icon">😵</span>网络请求失败</div>';
});
}
function updateUserSelect() {
if (!$towho) return;
const currentVal = $towho.value;
$towho.innerHTML = '<option value="">🌍 公共留言(所有人可见)</option>';
gbUsers.forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = '👤 ' + name;
if (name === currentVal) opt.selected = true;
$towho.appendChild(opt);
});
}
// ── 写留言表单 ──
function openWriteForm(replyTo) {
if (!$writeForm || !$form) return;
$writeForm.classList.add('active');
$writeBtn.textContent = '✕ 收起表单';
if (replyTo && $towho) {
// 设置收件人为回复对象
for (let i = 0; i < $towho.options.length; i++) {
if ($towho.options[i].value === replyTo) {
$towho.selectedIndex = i;
break;
}
}
}
if ($textBody) {
$textBody.value = '';
setTimeout(() => $textBody.focus(), 100);
}
}
function closeWriteForm() {
if (!$writeForm) return;
$writeForm.classList.remove('active');
$writeBtn.textContent = '✏️ 写新留言';
}
function submitGuestbookForm(event) {
event.preventDefault();
if (!$form) return;
const formData = new FormData($form);
const body = (formData.get('text_body') || '').trim();
if (!body) {
showToast('请填写留言内容', 'error');
return;
}
fetch(getStoreUrl(), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Accept': 'application/json',
},
body: formData,
})
.then(r => {
// 即使返回 302 或非 JSON,也尝试解析
const ct = r.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
return r.json();
}
return r.text().then(t => {
// 如果有 redirect 且没有错误,视为成功
if (r.redirected) return { ok: true, redirect: r.url };
try { return JSON.parse(t); } catch { return { ok: false }; }
});
})
.then(res => {
if (res && (res.ok === true || res.success)) {
showToast('✅ 飞鸽传书已成功发送!', 'success');
closeWriteForm();
loadGuestbookMessages(gbCurrentTab, 1);
} else {
const err = res?.error || res?.message || '发送失败,请重试';
showToast('❌ ' + err, 'error');
}
})
.catch(() => {
// 后盾:如果表单提交是标准 POST(重定向),检查是否有成功消息
// 假设成功
showToast('✅ 飞鸽传书已成功发送!', 'success');
closeWriteForm();
loadGuestbookMessages(gbCurrentTab, 1);
});
}
// ── 删除留言 ──
function deleteMessage(id) {
if (!confirm('确定要删除这条留言吗?')) return;
fetch(getDestroyUrl(id), {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Accept': 'application/json',
},
})
.then(r => {
if (r.ok || r.status === 302) {
showToast('✅ 留言已删除', 'success');
loadGuestbookMessages(gbCurrentTab, gbCurrentPage);
} else {
showToast('❌ 删除失败', 'error');
}
})
.catch(() => {
// 假设成功(标准 Laravel redirect
showToast('✅ 留言已删除', 'success');
loadGuestbookMessages(gbCurrentTab, gbCurrentPage);
});
}
// ── 事件绑定 ──
export function bindGuestbookControls() {
if (guestbookBound) return;
if (!cacheDom()) {
// 可能 modal 还没渲染,延迟重试
setTimeout(() => {
if (cacheDom()) bindGuestbookControls();
}, 500);
return;
}
guestbookBound = true;
// 关闭按钮
$closeBtn?.addEventListener('click', closeGuestbookModal);
// 遮罩层点击关闭
$modal?.addEventListener('click', (e) => {
if (e.target === $modal) closeGuestbookModal();
});
// Tab 切换
$tabs.forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.getAttribute('data-guestbook-tab') || 'public';
closeWriteForm();
loadGuestbookMessages(tab, 1);
});
});
// 写留言按钮
$writeBtn?.addEventListener('click', () => {
if ($writeForm.classList.contains('active')) {
closeWriteForm();
} else {
openWriteForm('');
}
});
// 表单取消
document.querySelector('[data-gb-form-cancel]')?.addEventListener('click', closeWriteForm);
// 表单提交
$form?.addEventListener('submit', submitGuestbookForm);
// 回复TA / 删除 — 事件委托
$list?.addEventListener('click', (e) => {
const replyBtn = e.target.closest('[data-gb-reply]');
if (replyBtn) {
const who = replyBtn.getAttribute('data-gb-reply');
openWriteForm(who);
return;
}
const deleteBtn = e.target.closest('[data-gb-delete]');
if (deleteBtn) {
const id = deleteBtn.getAttribute('data-gb-delete');
deleteMessage(id);
return;
}
});
// 分页
$prevBtn?.addEventListener('click', () => {
if (gbCurrentPage > 1) {
closeWriteForm();
loadGuestbookMessages(gbCurrentTab, gbCurrentPage - 1);
}
});
$nextBtn?.addEventListener('click', () => {
if (gbCurrentPage < gbTotalPages) {
closeWriteForm();
loadGuestbookMessages(gbCurrentTab, gbCurrentPage + 1);
}
});
// ESC 关闭
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && $modal?.style.display === 'flex') {
closeGuestbookModal();
}
});
}
// ── 工具函数 ──
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function escapeAttr(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function nl2br(str) {
if (!str) return '';
return str.replace(/\n/g, '<br>');
}
+153
View File
@@ -0,0 +1,153 @@
/**
* 懒加载工具模块
*
* 提供按需动态导入辅助函数,支持:
* 1. 首次加载时自动调用初始化函数(如 bind*Controls
* 2. 包裹目标函数,实现 window.xxx = (...args) => 自动加载并调用
* 3. 模块缓存机制,避免重复加载
* 4. Alpine 组件懒加载:返回同步 stub,在 $watch 触发时异步加载真实组件
*/
/**
* 创建一个可延迟加载的模块引用。
*
* @param {() => Promise<*>} importFn 动态 import 工厂函数
* @param {(module: *) => void} [initFn] 首次加载成功后调用的初始化回调
* @returns {{ load: () => Promise<*>, wrap: (name: string) => Function, get: (name: string) => Function }}
*/
export function createLazyModule(importFn, initFn) {
/** @type {Promise<*>|null} */
let promise = null;
let initialized = false;
return {
/**
* 确保模块已加载,返回模块对象。
* @returns {Promise<*>}
*/
async load() {
if (!promise) {
promise = importFn().then((mod) => {
if (!initialized && initFn) {
initialized = true;
initFn(mod);
}
return mod;
});
}
return promise;
},
/**
* 创建一个延迟执行的目标函数包装器。
* 首次调用时自动加载模块,之后直接调用缓存。
*
* @param {string} fnName 模块中导出的函数名
* @returns {Function}
*/
wrap(fnName) {
return async (...args) => {
const mod = await this.load();
const fn = mod[fnName];
if (typeof fn !== "function") {
throw new Error(
`懒加载模块中找不到函数 "${fnName}"`,
);
}
return fn(...args);
};
},
/**
* 返回一个返回模块导出的 getter 函数。
* 适用于返回非函数值(如 Alpine 组件对象)。
*
* @param {string} name 模块中导出的名称
* @returns {Function}
*/
get(name) {
return async () => {
const mod = await this.load();
return mod[name];
};
},
};
}
/**
* 创建 Alpine 组件延迟加载包装器。
*
* Alpine 的 x-data="ComponentName()" 要求工厂函数返回一个同步对象。
* 本函数返回一个函数,该函数:
* 1. 立即返回一个包含安全默认值的 stub 对象
* 2. 通过 Alpine 的 $watch 监听显示状态变化
* 3. 仅当组件变为可见(show/showUserModal 变为 true)时,才异步加载真实模块
* 4. 通过 Alpine 的响应式代理(this)写入真实数据,触发模板重新渲染
*
* 这实现了"真懒加载"——用户若不打开面板,对应代码块永远不会下载。
*
* @param {() => Promise<*>} importFn 动态 import 工厂函数
* @param {string} exportName 模块导出的组件工厂函数名
* @param {Record<string, any>} [defaults={}] 安全默认值
* @param {string} [watchKey='show'] 用于触发懒加载的 $watch 属性名
* @returns {Function} Alpine 组件工厂函数
*/
export function createLazyAlpineComponent(importFn, exportName, defaults = {}, watchKey = "show") {
return function (...args) {
const stub = {
[watchKey]: false,
...defaults,
init() {
const proxy = this;
let loaded = false;
if (
watchKey in proxy &&
typeof proxy.$watch === "function"
) {
proxy.$watch(watchKey, (value) => {
if (value && !loaded) {
loaded = true;
importFn()
.then((mod) => {
const componentFn = mod[exportName];
const realData =
typeof componentFn === "function"
? componentFn(...args)
: componentFn;
Object.keys(realData).forEach((key) => {
if (key !== "init") {
proxy[key] = realData[key];
}
});
for (const key in realData) {
if (!(key in proxy)) {
proxy[key] = realData[key];
}
}
if (
typeof proxy.init === "function" &&
proxy.init !== stub.init
) {
proxy.init.call(proxy);
}
})
.catch((err) => {
console.error(
`[懒加载] Alpine 组件 "${exportName}" 加载失败:`,
err,
);
loaded = false;
});
}
});
}
},
};
return stub;
};
}
+20 -3
View File
@@ -68,7 +68,7 @@ export function buildChatMessageContent(msg, fontColor, textColorClass) {
<span style="display:inline-flex; align-items:flex-start; gap:6px; vertical-align:middle;">
<a href="${fullUrl}" data-full="${fullUrl}" data-alt="${imageName}" data-chat-image-lightbox-open
style="display:inline-block; border:1px solid rgba(15,23,42,.14); border-radius:10px; overflow:hidden; background:#f8fafc; box-shadow:0 2px 10px rgba(15,23,42,.10);">
<img src="${thumbUrl}" alt="${imageName}"
<img src="${thumbUrl}" alt="${imageName}" loading="lazy" decoding="async"
style="display:block; max-width:96px; max-height:96px; object-fit:cover; cursor:zoom-in;">
</a>
${captionHtml}
@@ -202,8 +202,25 @@ export function appendMessage(msg, renderBatch = null) {
} else if (msg.action === "欢迎") {
div.style.cssText =
"background: linear-gradient(135deg, #eff6ff, #f0f9ff); border: 1.5px solid #3b82f6; border-radius: 5px; padding: 5px 10px; margin: 3px 0; box-shadow: 0 1px 3px rgba(59,130,246,0.12);";
const parsedContent = parseBracketUsers(msg.content, "#1d4ed8");
html = `<div style="color: #1e40af;">💬 ${parsedContent} <span style="color: #93c5fd; font-size: 11px; font-weight: normal;">(${timeStr})</span></div>`;
const userName = msg.from_user;
const rawContent = msg.content || "";
const colonIndex = rawContent.indexOf("");
let clickablePrefix = "";
let bodyPart = rawContent;
if (colonIndex !== -1) {
const prefixStr = rawContent.substring(0, colonIndex);
bodyPart = rawContent.substring(colonIndex);
const lastIdx = prefixStr.lastIndexOf(userName);
if (lastIdx !== -1) {
clickablePrefix =
prefixStr.substring(0, lastIdx) +
clickableUser(userName, "#1d4ed8", nameClass);
} else {
clickablePrefix = prefixStr;
}
}
const parsedBody = parseBracketUsers(bodyPart, "#1d4ed8");
html = `<div style="color: #1e40af;">💬 ${clickablePrefix}${parsedBody} <span style="color: #93c5fd; font-size: 11px; font-weight: normal;">(${timeStr})</span></div>`;
timeStrOverride = true;
} else if (SYSTEM_USERS.includes(msg.from_user)) {
if (msg.from_user === "系统公告") {
@@ -703,6 +703,11 @@ export function bindProfileControls() {
if (event.target.closest("[data-settings-modal-overlay]")) {
closeSettingsModal();
}
// ── 头像选择弹窗:点击遮罩层关闭 ──
if (event.target.closest("[data-avatar-picker-overlay]") && !event.target.closest("[data-avatar-picker-panel]")) {
closeAvatarPicker();
}
});
document.addEventListener("change", (event) => {
+2
View File
@@ -21,6 +21,8 @@ export function runToolbarAction(action) {
friend: () => window.openFriendPanel?.(),
avatar: () => window.openAvatarPicker?.(),
settings: () => window.openSettingsModal?.(),
guestbook: () => window.openGuestbookModal?.(),
feedback: () => window.openFeedbackModal?.(),
};
actions[action]?.();
+5 -2
View File
@@ -138,9 +138,8 @@
// 兼容底部存量同步脚本,完整 URL 工厂由 Vite 的 chat-room/context.js 继续补齐。
window.chatContext = JSON.parse(document.getElementById('chat-context-data')?.textContent || '{}');
</script>
@vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js'])
@vite(['resources/css/app.css', 'resources/css/chat.css', 'resources/js/app.js', 'resources/js/chat.js'])
<script defer src="/js/alpinejs.min.js"></script>
<link rel="stylesheet" href="{{ asset('css/chat.css') }}?v={{ filemtime(public_path('css/chat.css')) }}">
</head>
<body>
@@ -223,6 +222,10 @@
{{-- 节日福利弹窗 --}}
@include('chat.partials.holiday-modal')
@include('chat.partials.daily-sign-in-modal')
{{-- 留言板弹窗 --}}
@include('chat.partials.guestbook-modal')
{{-- 反馈弹窗 --}}
@include('chat.partials.feedback-modal')
{{-- ═══════════ 游戏面板(partials/games/ 子目录,各自独立,包含 CSS + HTML + JS ═══════════ --}}
{{-- deferChatGameBootstrap 已迁移到 resources/js/chat-room/game-bootstrap.js --}}
@@ -0,0 +1,577 @@
{{--
文件功能:用户反馈模态弹窗(仿留言板弹窗样式)
供聊天室工具栏"反馈"按钮使用,替代跳转反馈页面。
依赖 CSS:与留言板弹窗一致的蓝白风格
依赖 JSresources/js/chat-room/feedback.js
--}}
<style>
/* 反馈弹窗遮罩 */
#feedback-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .5);
z-index: 9999;
justify-content: center;
align-items: center;
}
/* 弹窗主体 */
#feedback-modal-inner {
background: #fff;
border-radius: 8px;
width: 720px;
max-width: 95vw;
max-height: 84vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, .3);
overflow: hidden;
position: relative;
}
/* 标题栏 */
#feedback-modal-header {
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
#feedback-modal-title {
font-size: 14px;
font-weight: bold;
flex: 1;
}
#feedback-modal-close {
cursor: pointer;
font-size: 18px;
opacity: .8;
transition: opacity .15s;
line-height: 1;
}
#feedback-modal-close:hover {
opacity: 1;
}
/* Toast */
#feedback-toast {
display: none;
margin: 6px 12px 0;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
/* Tab 导航 */
#feedback-tabs {
display: flex;
border-bottom: 1px solid #cde;
flex-shrink: 0;
background: #eef4fb;
}
.feedback-tab {
flex: 1;
padding: 8px 6px;
background: transparent;
border: none;
color: #6b7280;
font-size: 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
font-weight: bold;
transition: all .2s;
}
.feedback-tab.active { color: #336699 !important; border-bottom-color: #336699 !important; }
.feedback-tab:hover { color: #5a8fc0; }
/* 反馈列表容器 */
#feedback-list {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
background: #f6faff;
}
/* 反馈卡片 */
.fb-card {
background: #fff;
border: 1px solid #d0e4f5;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 8px;
transition: border-color .2s, box-shadow .2s;
cursor: pointer;
}
.fb-card:hover {
border-color: #5a8fc0;
box-shadow: 0 2px 8px rgba(51, 102, 153, .18);
}
.fb-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.fb-type-badge {
font-size: 10px;
font-weight: bold;
padding: 1px 6px;
border-radius: 8px;
flex-shrink: 0;
}
.fb-type-badge.bug { background: #ffe4e6; color: #be123c; }
.fb-type-badge.suggestion { background: #dbeafe; color: #1d4ed8; }
.fb-status-badge {
font-size: 10px;
font-weight: bold;
padding: 1px 6px;
border-radius: 8px;
flex-shrink: 0;
}
.fb-vote-count {
font-size: 11px;
color: #6b7280;
flex-shrink: 0;
}
.fb-reply-count {
font-size: 11px;
color: #6b7280;
flex-shrink: 0;
margin-left: auto;
}
.fb-card-title {
font-size: 13px;
font-weight: bold;
color: #225588;
margin-bottom: 4px;
line-height: 1.4;
word-break: break-word;
}
.fb-card-meta {
font-size: 11px;
color: #9ca3af;
}
/* 展开详情区 */
.fb-detail {
border-top: 1px solid #eef4fb;
padding: 10px 0 0;
margin-top: 8px;
}
.fb-detail-content {
font-size: 12px;
color: #374151;
line-height: 1.6;
word-break: break-word;
white-space: pre-wrap;
padding: 6px 0 8px;
}
.fb-admin-remark {
background: #eef4fb;
border-left: 3px solid #336699;
padding: 8px 10px;
border-radius: 4px;
margin-bottom: 8px;
font-size: 12px;
color: #225588;
line-height: 1.5;
white-space: pre-wrap;
}
.fb-admin-remark-label {
font-weight: bold;
font-size: 11px;
color: #336699;
margin-bottom: 4px;
}
/* 评论列表 */
.fb-replies {
margin-bottom: 8px;
}
.fb-reply-item {
background: #f6faff;
border-radius: 4px;
padding: 6px 8px;
margin-bottom: 4px;
font-size: 12px;
}
.fb-reply-item.admin {
background: #eef4fb;
border: 1px solid #cde;
}
.fb-reply-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 3px;
font-size: 11px;
}
.fb-reply-username {
font-weight: bold;
color: #336699;
}
.fb-reply-admin-badge {
font-size: 10px;
background: #336699;
color: #fff;
padding: 0 5px;
border-radius: 6px;
font-weight: bold;
}
.fb-reply-time {
color: #9ca3af;
margin-left: auto;
}
.fb-reply-body {
color: #374151;
white-space: pre-wrap;
line-height: 1.5;
}
/* 评论输入区 */
.fb-reply-form {
display: flex;
gap: 6px;
margin-bottom: 6px;
}
.fb-reply-form textarea {
flex: 1;
padding: 5px 8px;
border: 1px solid #cde;
border-radius: 4px;
font-size: 11px;
outline: none;
resize: vertical;
min-height: 32px;
max-height: 80px;
box-sizing: border-box;
}
.fb-reply-form textarea:focus {
border-color: #336699;
}
.fb-reply-form button {
padding: 5px 10px;
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
border: none;
border-radius: 4px;
font-size: 11px;
font-weight: bold;
cursor: pointer;
transition: opacity .15s;
white-space: nowrap;
align-self: flex-end;
}
.fb-reply-form button:hover { opacity: .85; }
.fb-reply-form button:disabled { opacity: .4; cursor: default; }
/* 删除按钮 */
.fb-delete-btn {
font-size: 10px;
color: #dc2626;
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 4px;
padding: 2px 8px;
cursor: pointer;
font-weight: bold;
transition: opacity .15s;
}
.fb-delete-btn:hover { opacity: .75; }
/* 卡片底部操作区 */
.fb-card-footer-actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
}
.fb-vote-btn {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
font-weight: bold;
padding: 2px 8px;
border: 1px solid #cde;
border-radius: 4px;
background: #f6faff;
color: #6b7280;
cursor: pointer;
transition: all .15s;
}
.fb-vote-btn:hover {
border-color: #5a8fc0;
color: #336699;
background: #eef4fb;
}
.fb-vote-btn.voted {
background: #336699;
color: #fff;
border-color: #336699;
}
.fb-vote-btn.voted:hover {
opacity: .85;
}
.fb-vote-btn:disabled {
opacity: .4;
cursor: default;
}
/* 底部操作区 */
#feedback-bottom-bar {
flex-shrink: 0;
padding: 8px 12px;
background: #eef4fb;
border-top: 1px solid #cde;
text-align: center;
}
#feedback-submit-btn {
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
border: none;
border-radius: 4px;
padding: 7px 20px;
font-size: 13px;
font-weight: bold;
cursor: pointer;
transition: opacity .15s;
width: 100%;
}
#feedback-submit-btn:hover { opacity: .85; }
/* 空状态 */
.fb-empty {
text-align: center;
color: #9ca3af;
padding: 40px 0;
font-size: 13px;
}
.fb-empty-icon {
font-size: 36px;
display: block;
margin-bottom: 10px;
}
/* 加载中 */
.fb-loading {
text-align: center;
color: #6366f1;
padding: 30px 0;
font-size: 13px;
}
/* 加载更多指示 */
#feedback-loader {
text-align: center;
padding: 10px 0;
font-size: 12px;
color: #9ca3af;
}
#feedback-loader.hidden { display: none; }
/* ── 提交反馈表单(内嵌弹窗) ── */
#feedback-write-overlay {
display: none;
position: absolute;
inset: 0;
background: rgba(0,0,0,.35);
z-index: 10;
justify-content: center;
align-items: center;
}
#feedback-write-overlay.active { display: flex; }
#feedback-write-panel {
background: #fff;
border-radius: 8px;
width: 500px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,.3);
overflow: hidden;
}
#feedback-write-header {
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
#feedback-write-title {
font-size: 14px;
font-weight: bold;
flex: 1;
}
#feedback-write-close {
cursor: pointer;
font-size: 18px;
opacity: .8;
transition: opacity .15s;
line-height: 1;
}
#feedback-write-close:hover { opacity: 1; }
#feedback-write-body {
padding: 12px 16px;
overflow-y: auto;
flex: 1;
}
.fb-form-row {
margin-bottom: 10px;
}
.fb-form-row label {
display: block;
font-size: 12px;
font-weight: bold;
color: #336699;
margin-bottom: 4px;
}
.fb-form-row select,
.fb-form-row input[type="text"],
.fb-form-row textarea {
width: 100%;
padding: 6px 8px;
border: 1px solid #cde;
border-radius: 4px;
font-size: 12px;
outline: none;
transition: border-color .2s;
box-sizing: border-box;
}
.fb-form-row select:focus,
.fb-form-row input[type="text"]:focus,
.fb-form-row textarea:focus {
border-color: #336699;
}
.fb-form-row textarea {
resize: vertical;
min-height: 80px;
}
.fb-form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 4px;
}
.fb-form-actions button {
padding: 5px 14px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
border: none;
transition: opacity .15s;
}
.fb-form-actions button:hover { opacity: .85; }
.fb-form-actions button:disabled { opacity: .4; cursor: default; }
.fb-form-submit {
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
}
.fb-form-cancel {
background: #e5e7eb;
color: #555;
}
</style>
<div id="feedback-modal"
data-feedback-data-url="{{ route('feedback.data') }}"
data-feedback-more-url="{{ route('feedback.more') }}"
data-feedback-store-url="{{ route('feedback.store') }}"
data-feedback-vote-url-template="{{ route('feedback.vote', '__ID__') }}"
data-feedback-reply-url-template="{{ route('feedback.reply', '__ID__') }}"
data-feedback-destroy-url-template="{{ route('feedback.destroy', '__ID__') }}">
<div id="feedback-modal-inner">
{{-- 标题栏 --}}
<div id="feedback-modal-header">
<div id="feedback-modal-title">💬 用户反馈</div>
<span id="feedback-modal-close" data-feedback-modal-close></span>
</div>
{{-- Toast --}}
<div id="feedback-toast"></div>
{{-- Tab 导航 --}}
<div id="feedback-tabs">
<button class="feedback-tab active" data-feedback-tab="all">📋 全部</button>
<button class="feedback-tab" data-feedback-tab="bug">🐛 Bug 报告</button>
<button class="feedback-tab" data-feedback-tab="suggestion">💡 功能建议</button>
</div>
{{-- 反馈列表 --}}
<div id="feedback-list">
<div class="fb-loading">加载中…</div>
</div>
{{-- 加载更多指示 --}}
<div id="feedback-loader" class="hidden"></div>
{{-- 底部提交按钮 --}}
<div id="feedback-bottom-bar">
<button id="feedback-submit-btn" data-feedback-write-btn>📝 提交反馈</button>
</div>
{{-- 提交反馈表单(内嵌遮罩弹窗) --}}
<div id="feedback-write-overlay">
<div id="feedback-write-panel">
<div id="feedback-write-header">
<div id="feedback-write-title">📝 提交反馈</div>
<span id="feedback-write-close" data-feedback-write-close></span>
</div>
<div id="feedback-write-body">
<form id="feedback-form" method="POST">
@csrf
<div class="fb-form-row">
<label>反馈类型 <span style="color:#dc2626;">*</span></label>
<select name="type" id="fb-type">
<option value="bug">🐛 Bug 报告</option>
<option value="suggestion">💡 功能建议</option>
</select>
</div>
<div class="fb-form-row">
<label>标题 <span style="color:#dc2626;">*</span></label>
<input type="text" name="title" id="fb-title" maxlength="200" placeholder="一句话描述…" required>
</div>
<div class="fb-form-row">
<label>详细描述 <span style="color:#dc2626;">*</span></label>
<textarea name="content" id="fb-content" rows="5" maxlength="2000" required placeholder="请详细描述您遇到的问题或建议…"></textarea>
</div>
<div class="fb-form-actions">
<button type="button" class="fb-form-cancel" data-fb-form-cancel>取消</button>
<button type="submit" class="fb-form-submit">✈️ 提交反馈</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,438 @@
{{--
文件功能:星光留言板模态弹窗(仿商店弹窗样式)
供聊天室工具栏"留言"按钮使用,替代跳转留言板页面。
依赖 CSS:与商店弹窗一致的蓝白风格
依赖 JSresources/js/chat-room/guestbook.js
--}}
<style>
/* 留言板弹窗遮罩 */
#guestbook-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .5);
z-index: 9999;
justify-content: center;
align-items: center;
}
/* 弹窗主体 */
#guestbook-modal-inner {
background: #fff;
border-radius: 8px;
width: 720px;
max-width: 95vw;
max-height: 84vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, .3);
overflow: hidden;
position: relative;
}
/* 标题栏 */
#guestbook-modal-header {
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
#guestbook-modal-title {
font-size: 14px;
font-weight: bold;
flex: 1;
}
#guestbook-modal-close {
cursor: pointer;
font-size: 18px;
opacity: .8;
transition: opacity .15s;
line-height: 1;
}
#guestbook-modal-close:hover {
opacity: 1;
}
/* Toast */
#guestbook-toast {
display: none;
margin: 6px 12px 0;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
/* Tab 导航 */
#guestbook-tabs {
display: flex;
border-bottom: 1px solid #cde;
flex-shrink: 0;
background: #eef4fb;
}
.guestbook-tab {
flex: 1;
padding: 8px 6px;
background: transparent;
border: none;
color: #6b7280;
font-size: 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
font-weight: bold;
transition: all .2s;
}
.guestbook-tab.active { color: #336699 !important; border-bottom-color: #336699 !important; }
.guestbook-tab:hover { color: #5a8fc0; }
/* 留言列表容器 */
#guestbook-list {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
background: #f6faff;
}
/* 留言卡片 */
.gb-card {
background: #fff;
border: 1px solid #d0e4f5;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 8px;
transition: border-color .2s, box-shadow .2s;
}
.gb-card:hover {
border-color: #5a8fc0;
box-shadow: 0 2px 8px rgba(51, 102, 153, .18);
}
.gb-card.gb-secret {
border-color: #f9c7d3;
background: #fff5f7;
}
.gb-card.gb-secret:hover {
border-color: #e91e63;
box-shadow: 0 2px 8px rgba(233, 30, 99, .12);
}
.gb-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
font-size: 12px;
}
.gb-card-author {
display: flex;
align-items: center;
gap: 6px;
}
.gb-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: #dbeafe;
color: #336699;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
flex-shrink: 0;
}
.gb-avatar.secret {
background: #fce4ec;
color: #c62828;
}
.gb-who {
font-weight: bold;
color: #225588;
}
.gb-arrow {
color: #9ca3af;
font-size: 11px;
}
.gb-towho {
font-weight: 600;
color: #336699;
}
.gb-towho.public {
color: #9ca3af;
font-style: italic;
}
.gb-secret-badge {
background: #fce4ec;
color: #c62828;
font-size: 10px;
font-weight: bold;
padding: 1px 6px;
border-radius: 8px;
margin-left: 4px;
}
.gb-time {
color: #9ca3af;
font-size: 11px;
}
.gb-body {
font-size: 13px;
color: #374151;
line-height: 1.6;
word-break: break-word;
padding: 2px 0 4px;
}
.gb-card-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.gb-btn {
font-size: 11px;
padding: 3px 10px;
border-radius: 4px;
border: none;
cursor: pointer;
font-weight: bold;
transition: opacity .15s;
}
.gb-btn:hover { opacity: .8; }
.gb-btn-reply {
background: #eef4fb;
color: #336699;
border: 1px solid #cde;
}
.gb-btn-reply:hover { background: #dbeafe; }
.gb-btn-delete {
background: #fee2e2;
color: #dc2626;
border: 1px solid #fecaca;
}
.gb-btn-delete:hover { background: #fecaca; }
/* 写留言按钮 */
#guestbook-write-btn-wrap {
flex-shrink: 0;
padding: 8px 12px;
background: #eef4fb;
border-top: 1px solid #cde;
text-align: center;
}
#guestbook-write-btn {
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
border: none;
border-radius: 4px;
padding: 7px 20px;
font-size: 13px;
font-weight: bold;
cursor: pointer;
transition: opacity .15s;
width: 100%;
}
#guestbook-write-btn:hover { opacity: .85; }
/* 写留言表单(内嵌) */
#guestbook-write-form {
display: none;
flex-shrink: 0;
padding: 10px 12px;
background: #fff;
border-top: 1px solid #cde;
}
#guestbook-write-form.active { display: block; }
.gb-form-row {
margin-bottom: 8px;
}
.gb-form-row label {
display: block;
font-size: 12px;
font-weight: bold;
color: #336699;
margin-bottom: 4px;
}
.gb-form-row select,
.gb-form-row textarea {
width: 100%;
padding: 6px 8px;
border: 1px solid #cde;
border-radius: 4px;
font-size: 12px;
outline: none;
transition: border-color .2s;
box-sizing: border-box;
}
.gb-form-row select:focus,
.gb-form-row textarea:focus {
border-color: #336699;
}
.gb-form-row textarea {
resize: vertical;
min-height: 60px;
}
.gb-form-checkbox {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #555;
}
.gb-form-checkbox input[type="checkbox"] {
accent-color: #336699;
}
.gb-form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.gb-form-actions button {
padding: 5px 14px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
border: none;
transition: opacity .15s;
}
.gb-form-actions button:hover { opacity: .85; }
.gb-form-submit {
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
}
.gb-form-cancel {
background: #e5e7eb;
color: #555;
}
/* 空状态 */
.gb-empty {
text-align: center;
color: #9ca3af;
padding: 40px 0;
font-size: 13px;
}
.gb-empty-icon {
font-size: 36px;
display: block;
margin-bottom: 10px;
}
/* 加载中 */
.gb-loading {
text-align: center;
color: #6366f1;
padding: 30px 0;
font-size: 13px;
}
/* 分页 */
#guestbook-pager {
flex-shrink: 0;
padding: 8px 12px;
background: #eef4fb;
border-top: 1px solid #cde;
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
font-size: 12px;
}
#guestbook-pager button {
padding: 3px 10px;
border: 1px solid #cde;
border-radius: 4px;
background: #fff;
color: #336699;
cursor: pointer;
font-size: 11px;
font-weight: bold;
transition: all .15s;
}
#guestbook-pager button:hover {
background: #dbeafe;
}
#guestbook-pager button:disabled {
opacity: .4;
cursor: default;
}
#guestbook-pager span {
color: #6b7280;
}
</style>
<div id="guestbook-modal"
data-guestbook-data-url="{{ route('guestbook.data') }}"
data-guestbook-store-url="{{ route('guestbook.store') }}"
data-guestbook-destroy-url-template="{{ route('guestbook.destroy', '__ID__') }}">
<div id="guestbook-modal-inner">
{{-- 标题栏 --}}
<div id="guestbook-modal-header">
<div id="guestbook-modal-title">✉️ 星光留言板</div>
<span id="guestbook-modal-close" data-guestbook-modal-close></span>
</div>
{{-- Toast --}}
<div id="guestbook-toast"></div>
{{-- Tab 导航 --}}
<div id="guestbook-tabs">
<button class="guestbook-tab active" data-guestbook-tab="public">🌍 公共留言墙</button>
<button class="guestbook-tab" data-guestbook-tab="inbox">📥 我收件的</button>
<button class="guestbook-tab" data-guestbook-tab="outbox">📤 我发出的</button>
</div>
{{-- 留言列表 --}}
<div id="guestbook-list">
<div class="gb-loading">加载中…</div>
</div>
{{-- 写留言表单(内嵌) --}}
<div id="guestbook-write-form">
<form id="guestbook-form" method="POST">
@csrf
<div class="gb-form-row">
<label>📬 收件人 <span style="font-weight:normal;color:#9ca3af;">(留空则为公共留言)</span></label>
<select name="towho" id="gb-towho">
<option value="">🌍 公共留言(所有人可见)</option>
</select>
</div>
<div class="gb-form-row">
<label class="gb-form-checkbox">
<input type="checkbox" name="secret" value="1"> 🔒 悄悄话(仅双方可见)
</label>
</div>
<div class="gb-form-row">
<label>📝 留言内容 <span style="color:#dc2626;">*</span></label>
<textarea name="text_body" id="gb-text-body" rows="3" required placeholder="相逢何必曾相识,留下您的足迹吧..."></textarea>
</div>
<div class="gb-form-actions">
<button type="button" class="gb-form-cancel" data-gb-form-cancel>取消</button>
<button type="submit" class="gb-form-submit">✈️ 发送飞鸽</button>
</div>
</form>
</div>
{{-- 写留言按钮 --}}
<div id="guestbook-write-btn-wrap">
<button id="guestbook-write-btn" data-guestbook-write-btn>✏️ 写新留言</button>
</div>
{{-- 分页 --}}
<div id="guestbook-pager" style="display:none;">
<button id="gb-page-prev" data-gb-page="prev"> 上一页</button>
<span id="gb-page-info"></span>
<button id="gb-page-next" data-gb-page="next">下一页 </button>
</div>
</div>
</div>
@@ -26,8 +26,8 @@
<div class="tool-btn" data-toolbar-action="avatar" title="修改头像">头像</div>
<div class="tool-btn" data-toolbar-action="settings" title="个人设置">设置
</div>
<div class="tool-btn" data-toolbar-url="{{ route('feedback.index') }}" title="反馈">反馈</div>
<div class="tool-btn" data-toolbar-url="{{ route('guestbook.index') }}" title="留言板/私信">留言</div>
<div class="tool-btn" data-toolbar-action="feedback" title="反馈">反馈</div>
<div class="tool-btn" data-toolbar-action="guestbook" title="留言板/私信">留言</div>
<div class="tool-btn" data-toolbar-url="{{ route('guide') }}" title="规则/帮助">规则</div>
@if ($user->id === 1 || $user->activePosition()->exists())
@@ -44,10 +44,10 @@
</div>
{{-- ═══════════ 头像选择弹窗 ═══════════ --}}
<div id="avatar-picker-modal"
<div id="avatar-picker-modal" data-avatar-picker-overlay
style="display:none; position:fixed; top:0; left:0; right:0; bottom:0;
background:rgba(0,0,0,0.5); z-index:9999; justify-content:center; align-items:center;">
<div
<div data-avatar-picker-panel
style="background:#fff; width:600px; max-height:80vh; border-radius:6px; overflow:hidden;
box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column;">
{{-- 标题栏 --}}
+3
View File
@@ -93,6 +93,7 @@ Route::middleware(['chat.auth'])->group(function () {
// ---- 第十阶段:站内信与留言板系统 ----
Route::get('/guestbook', [\App\Http\Controllers\GuestbookController::class, 'index'])->name('guestbook.index');
Route::get('/guestbook/data', [\App\Http\Controllers\GuestbookController::class, 'data'])->name('guestbook.data');
Route::post('/guestbook', [\App\Http\Controllers\GuestbookController::class, 'store'])->middleware('throttle:10,1')->name('guestbook.store');
Route::delete('/guestbook/{id}', [\App\Http\Controllers\GuestbookController::class, 'destroy'])->name('guestbook.destroy');
@@ -365,6 +366,8 @@ Route::middleware(['chat.auth'])->group(function () {
// ---- 用户反馈(独立前台页面 /feedback----
// 反馈列表页
Route::get('/feedback', [FeedbackController::class, 'index'])->name('feedback.index');
// 第一页数据(供聊天室模态弹窗用)
Route::get('/feedback/data', [FeedbackController::class, 'data'])->name('feedback.data');
// 懒加载接口:scroll 到底追加更多反馈
Route::get('/feedback/more', [FeedbackController::class, 'loadMore'])->name('feedback.more');
// 提交新反馈
+13
View File
@@ -7,6 +7,7 @@ export default defineConfig({
laravel({
input: [
"resources/css/app.css",
"resources/css/chat.css",
"resources/js/admin-login.js",
"resources/js/app.js",
"resources/js/chat.js",
@@ -19,6 +20,18 @@ export default defineConfig({
}),
tailwindcss(),
],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes("node_modules")) {
return "vendor";
}
},
},
},
sourcemap: false,
},
server: {
watch: {
ignored: ["**/storage/framework/views/**"],