Files
chatroom/resources/views/feedback/index.blade.php
lkddi 5f30220609 feat: 任命/撤销通知系统 + 用户名片UI优化
- 任命/撤销事件增加 type 字段区分类型
- 任命:全屏礼花 + 紫色弹窗 + 紫色系统消息
- 撤销:灰色弹窗 + 灰色系统消息,无礼花
- 消息分发:操作者/被操作者显示在私聊面板,其他人显示在公屏
- 系统消息加随机鼓励语(各5条轮换)
- ChatStateService 修复 Redis key 前缀扫描问题(getAllActiveRoomIds)
- 用户名片折叠优化:管理员视野、职务履历均可折叠
- 管理操作 + 职务操作合并为「🔧 管理操作」折叠区
- 悄悄话改为「🎁 送礼物」按钮,礼物面板内联展开
2026-02-28 23:44:38 +08:00

481 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{--
文件功能:用户反馈前台独立页面(/feedback
用户可提交 Bug 报告或功能建议可赞同Toggle、补充评论
支持按类型筛选(全部/Bug/建议),懒加载列表
右上角按钮打开提交 Modal
@extends layouts.app
--}}
@extends('layouts.app')
@section('title', '用户反馈 - 飘落流星')
@section('nav-icon', '💬')
@section('nav-title', '用户反馈')
@section('content')
<div class="max-w-3xl mx-auto py-8 px-4 sm:px-6" x-data="{
// 列表状态
items: [],
lastId: null,
hasMore: true,
loading: false,
filterType: 'all',
// 展开/评论状态
expandedId: null,
replyContent: {},
submittingReply: {},
// 提交 Modal 状态
showModal: false,
submitting: false,
form: { type: 'bug', title: '', content: '' },
// 当前用户已赞同的反馈 ID set
myVotedIds: new Set({{ json_encode($myVotedIds) }}),
init() {
// SSR 首屏数据
const raw = {{ json_encode(
$feedbacks->map(
fn($f) => [
'id' => $f->id,
'type' => $f->type,
'type_label' => $f->type_label,
'title' => $f->title,
'content' => $f->content,
'status' => $f->status,
'status_label' => $f->status_label,
'status_color' => $f->status_config['color'],
'admin_remark' => $f->admin_remark,
'votes_count' => $f->votes_count,
'replies_count' => $f->replies_count,
'username' => $f->username,
'created_at' => $f->created_at->diffForHumans(),
'replies' => $f->replies->map(
fn($r) => [
'id' => $r->id,
'username' => $r->username,
'content' => $r->content,
'is_admin' => $r->is_admin,
'created_at' => $r->created_at->diffForHumans(),
],
)->values()->toArray(),
],
),
) }};
this.items = raw.map(f => ({ ...f, voted: this.myVotedIds.has(f.id) }));
if (this.items.length > 0) {
this.lastId = this.items[this.items.length - 1].id;
this.hasMore = this.items.length >= 10;
} else {
this.hasMore = false;
}
},
// 懒加载更多
async loadMore() {
if (this.loading || !this.hasMore) return;
this.loading = true;
try {
const typeParam = this.filterType !== 'all' ? `&type=${this.filterType}` : '';
const res = await fetch(`/feedback/more?after_id=${this.lastId}${typeParam}`);
const data = await res.json();
const newItems = data.items.map(f => ({ ...f, voted: this.myVotedIds.has(f.id) }));
this.items.push(...newItems);
if (data.items.length > 0) this.lastId = data.items[data.items.length - 1].id;
this.hasMore = data.has_more;
} catch (e) { console.error(e); } finally { this.loading = false; }
},
// 切换类型筛选(重置并重新加载)
async switchType(type) {
this.filterType = type;
this.items = [];
this.lastId = 999999999;
this.hasMore = true;
this.expandedId = null;
await this.loadMore();
},
// 提交新反馈
async submitFeedback() {
if (this.submitting) return;
if (!this.form.title.trim() || !this.form.content.trim()) {
alert('请填写标题和详细描述!');
return;
}
this.submitting = true;
try {
const res = await fetch('/feedback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Accept': 'application/json',
},
body: JSON.stringify(this.form),
});
const data = await res.json();
if (data.status === 'success') {
this.items.unshift(data.item);
this.showModal = false;
this.form = { type: 'bug', title: '', content: '' };
} else {
alert(data.message || '提交失败,请重试');
}
} catch (e) { alert('网络异常,请重试'); } finally { this.submitting = false; }
},
// 赞同/取消赞同(乐观更新)
async toggleVote(feedbackId) {
const item = this.items.find(f => f.id === feedbackId);
if (!item) return;
const prev = { voted: item.voted, count: item.votes_count };
item.voted = !item.voted;
item.votes_count += item.voted ? 1 : -1;
try {
const res = await fetch(`/feedback/${feedbackId}/vote`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Accept': 'application/json',
},
});
const data = await res.json();
if (data.status !== 'success') {
// 回滚
item.voted = prev.voted;
item.votes_count = prev.count;
alert(data.message || '操作失败');
} else {
item.votes_count = data.votes_count;
if (data.voted) {
this.myVotedIds.add(feedbackId);
} else {
this.myVotedIds.delete(feedbackId);
}
}
} catch (e) {
item.voted = prev.voted;
item.votes_count = prev.count;
}
},
// 提交补充评论
async submitReply(feedbackId) {
const content = (this.replyContent[feedbackId] || '').trim();
if (!content) return;
this.submittingReply[feedbackId] = true;
try {
const res = await fetch(`/feedback/${feedbackId}/reply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Accept': 'application/json',
},
body: JSON.stringify({ content }),
});
const data = await res.json();
if (data.status === 'success') {
const item = this.items.find(f => f.id === feedbackId);
if (item) {
item.replies.push(data.reply);
item.replies_count++;
}
this.replyContent[feedbackId] = '';
} else {
alert(data.message || '评论失败');
}
} catch (e) { alert('网络异常'); } finally { this.submittingReply[feedbackId] = false; }
},
// 辅助:状态徽标 CSS 类
statusClass(color) {
const map = {
gray: 'bg-gray-100 text-gray-600',
green: 'bg-green-100 text-green-700',
blue: 'bg-blue-100 text-blue-700',
emerald: 'bg-emerald-100 text-emerald-700',
red: 'bg-red-100 text-red-700',
orange: 'bg-orange-100 text-orange-700',
};
return map[color] || 'bg-gray-100 text-gray-600';
},
}">
{{-- ═══ 页面标题 + 提交按钮 ═══ --}}
<div class="flex items-start justify-between mb-6">
<div>
<h2 class="text-2xl font-extrabold text-gray-800">💬 用户反馈</h2>
<p class="text-sm text-gray-500 mt-1">提交 Bug 报告或功能建议,开发者会跟进处理</p>
</div>
<button @click="showModal = true"
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2.5 rounded-xl font-bold text-sm shadow-md hover:shadow-lg transition-all flex items-center gap-2 shrink-0">
<span class="text-lg leading-none"></span> 提交反馈
</button>
</div>
{{-- ═══ 类型筛选 Tab ═══ --}}
<div class="flex gap-2 mb-5">
<button @click="switchType('all')"
:class="filterType === 'all' ? 'bg-indigo-600 text-white shadow-sm' :
'bg-white text-gray-600 border border-gray-200 hover:border-indigo-300 hover:text-indigo-600'"
class="px-4 py-2 rounded-full text-xs font-bold transition-all">全部</button>
<button @click="switchType('bug')"
:class="filterType === 'bug' ? 'bg-rose-600 text-white shadow-sm' :
'bg-white text-gray-600 border border-gray-200 hover:border-rose-300 hover:text-rose-600'"
class="px-4 py-2 rounded-full text-xs font-bold transition-all">🐛 Bug 报告</button>
<button @click="switchType('suggestion')"
:class="filterType === 'suggestion' ? 'bg-blue-600 text-white shadow-sm' :
'bg-white text-gray-600 border border-gray-200 hover:border-blue-300 hover:text-blue-600'"
class="px-4 py-2 rounded-full text-xs font-bold transition-all">💡 功能建议</button>
</div>
{{-- ═══ 空状态 ═══ --}}
<template x-if="items.length === 0 && !loading">
<div class="text-center py-24 text-gray-400">
<p class="text-6xl mb-4">💬</p>
<p class="text-lg font-bold text-gray-500">还没有任何反馈</p>
<p class="text-sm mt-2">点击「提交反馈」成为第一个贡献者!</p>
</div>
</template>
{{-- ═══ 反馈列表 ═══ --}}
<div class="space-y-3">
<template x-for="item in items" :key="item.id">
<div class="bg-white border border-gray-100 rounded-2xl shadow-sm overflow-hidden
hover:shadow-md transition-shadow duration-200"
:class="{ 'ring-2 ring-indigo-200': expandedId === item.id }">
{{-- 卡片主区(点击展开/收起) --}}
<div class="flex items-center gap-3 p-4 cursor-pointer"
@click="expandedId = expandedId === item.id ? null : item.id">
{{-- 赞同按钮 --}}
<div class="shrink-0" @click.stop>
<button @click="toggleVote(item.id)"
class="flex flex-col items-center justify-center w-12 h-14 rounded-xl border-2 font-bold transition-all"
:class="item.voted ?
'bg-indigo-600 border-indigo-600 text-white shadow-md' :
'border-gray-200 text-gray-500 hover:border-indigo-400 hover:text-indigo-600 hover:bg-indigo-50'">
<span class="text-base leading-none" x-text="item.voted ? '👍' : '👆'"></span>
<span class="text-xs mt-1 font-bold" x-text="item.votes_count"></span>
</button>
</div>
{{-- 内容摘要 --}}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap mb-1.5">
{{-- 类型标签 --}}
<span class="px-2 py-0.5 rounded text-xs font-bold shrink-0"
:class="item.type === 'bug' ? 'bg-rose-100 text-rose-700' : 'bg-blue-100 text-blue-700'"
x-text="item.type_label"></span>
{{-- 状态标签 --}}
<span class="px-2 py-0.5 rounded text-xs font-bold shrink-0"
:class="statusClass(item.status_color)" x-text="item.status_label"></span>
{{-- 评论数 --}}
<span class="text-gray-400 text-xs" x-text="'💬 ' + item.replies_count + ' 条'"></span>
</div>
<h4 class="font-bold text-gray-800 text-sm leading-snug truncate" x-text="item.title"></h4>
<p class="text-gray-400 text-xs mt-1" x-text="'by ' + item.username + ' · ' + item.created_at">
</p>
</div>
{{-- 展开指示箭头 --}}
<div class="shrink-0 text-gray-300 transition-transform duration-200"
:class="{ 'rotate-180 text-indigo-400': expandedId === item.id }">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{{-- 展开详情区 --}}
<div x-show="expandedId === item.id" x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0" class="border-t border-gray-100">
{{-- 详细描述 --}}
<div class="px-5 py-4 bg-gray-50/60">
<p class="text-xs font-bold text-gray-400 mb-2">📝 详细描述</p>
<p class="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap" x-text="item.content"></p>
</div>
{{-- 管理员官方回复 --}}
<template x-if="item.admin_remark">
<div class="mx-4 my-3 p-4 bg-indigo-50 border-l-4 border-indigo-400 rounded-r-xl">
<p class="text-xs font-bold text-indigo-700 mb-1.5">🛡️ 开发者官方回复</p>
<p class="text-sm text-indigo-800 whitespace-pre-wrap leading-relaxed"
x-text="item.admin_remark"></p>
</div>
</template>
{{-- 补充评论列表 --}}
<template x-if="item.replies && item.replies.length > 0">
<div class="px-4 py-3 border-t border-gray-100 space-y-2">
<p class="text-xs font-bold text-gray-400 mb-2">💬 补充评论 (<span
x-text="item.replies_count"></span>)</p>
<template x-for="reply in item.replies" :key="reply.id">
<div class="rounded-xl px-3 py-2.5"
:class="reply.is_admin ?
'bg-indigo-50 border border-indigo-200' :
'bg-gray-100'">
<div class="flex items-center gap-2 mb-1">
<span class="font-bold text-xs"
:class="reply.is_admin ? 'text-indigo-700' : 'text-gray-700'"
x-text="reply.username"></span>
<template x-if="reply.is_admin">
<span
class="text-xs bg-indigo-200 text-indigo-800 px-1.5 rounded font-bold">开发者</span>
</template>
<span class="text-gray-400 text-xs ml-auto" x-text="reply.created_at"></span>
</div>
<p class="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed"
x-text="reply.content"></p>
</div>
</template>
</div>
</template>
{{-- 评论输入框 --}}
<div class="px-4 pb-4 pt-2 border-t border-gray-100">
<div class="flex gap-2">
<textarea x-model="replyContent[item.id]" placeholder="补充说明、复现步骤或相关信息..." rows="2" maxlength="1000"
class="flex-1 border border-gray-200 rounded-xl text-sm px-3 py-2 resize-none
focus:ring-2 focus:ring-indigo-400 focus:border-transparent outline-none
placeholder:text-gray-400"></textarea>
<button @click="submitReply(item.id)"
:disabled="submittingReply[item.id] || !replyContent[item.id]?.trim()"
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-xl
text-xs font-bold disabled:opacity-40 transition-all self-end shrink-0">
发送
</button>
</div>
</div>
</div>
</div>
</template>
{{-- 懒加载哨兵 --}}
<div x-show="hasMore && items.length > 0" x-intersect.threshold.10="loadMore()" class="py-4 text-center">
<template x-if="loading">
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z">
</path>
</svg>
加载更多...
</div>
</template>
</div>
<div x-show="!hasMore && items.length > 0" class="text-center py-6 text-gray-400 text-sm">
<div class="inline-flex items-center gap-2">
<div class="h-px w-16 bg-gray-200"></div>
<span>以上是全部反馈</span>
<div class="h-px w-16 bg-gray-200"></div>
</div>
</div>
</div>
{{-- ═══════════ 提交反馈 Modal必须在 x-data 容器内)═══════════ --}}
<div x-show="showModal" style="display:none;" class="fixed inset-0 z-[200] flex items-center justify-center p-4"
x-transition.opacity>
{{-- 蒙板 --}}
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" @click="showModal = false"></div>
{{-- 弹窗 --}}
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden"
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100">
{{-- 头部 --}}
<div class="bg-gradient-to-r from-indigo-600 to-violet-600 px-6 py-5 flex items-center justify-between">
<div>
<h3 class="text-white font-bold text-lg">📝 提交反馈</h3>
<p class="text-indigo-200 text-xs mt-0.5">您的反馈将帮助我们改进产品</p>
</div>
<button @click="showModal = false"
class="text-white/60 hover:text-white text-2xl font-light transition leading-none">&times;</button>
</div>
{{-- 表单 --}}
<div class="p-6 space-y-4">
{{-- 类型选择 --}}
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">反馈类型 <span
class="text-red-500">*</span></label>
<div class="grid grid-cols-2 gap-2">
<label class="relative cursor-pointer">
<input type="radio" x-model="form.type" value="bug" class="peer sr-only">
<div
class="border-2 border-gray-200 peer-checked:border-rose-500 peer-checked:bg-rose-50 rounded-xl p-3 text-center transition">
<span class="text-2xl block mb-1">🐛</span>
<span class="text-xs font-bold text-gray-700">Bug 报告</span>
<p class="text-xs text-gray-400 mt-0.5">发现了问题</p>
</div>
</label>
<label class="relative cursor-pointer">
<input type="radio" x-model="form.type" value="suggestion" class="peer sr-only">
<div
class="border-2 border-gray-200 peer-checked:border-blue-500 peer-checked:bg-blue-50 rounded-xl p-3 text-center transition">
<span class="text-2xl block mb-1">💡</span>
<span class="text-xs font-bold text-gray-700">功能建议</span>
<p class="text-xs text-gray-400 mt-0.5">希望改进或新增</p>
</div>
</label>
</div>
</div>
{{-- 标题 --}}
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">
标题 <span class="text-red-500">*</span>
<span class="font-normal text-gray-400 ml-1 text-xs">(一句话描述)</span>
</label>
<input x-model="form.title" type="text" maxlength="200" placeholder="例:点击发送按钮后页面空白..."
class="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm
focus:ring-2 focus:ring-indigo-400 focus:border-transparent outline-none">
<p class="text-xs text-gray-400 mt-1" x-text="form.title.length + '/200'"></p>
</div>
{{-- 详细描述 --}}
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">
详细描述 <span class="text-red-500">*</span>
</label>
<textarea x-model="form.content" rows="5" maxlength="2000"
:placeholder="form.type === 'bug' ?
'请描述:\n1. 触发 Bug 的操作步骤\n2. 实际看到的现象\n3. 期望的正确行为' :
'请描述:\n1. 您希望实现什么功能\n2. 这个功能对您有什么帮助'"
class="w-full border border-gray-200 rounded-xl px-4 py-3 text-sm resize-none
focus:ring-2 focus:ring-indigo-400 focus:border-transparent outline-none leading-relaxed"></textarea>
<p class="text-xs text-gray-400 mt-1" x-text="form.content.length + '/2000'"></p>
</div>
{{-- 操作按钮 --}}
<div class="flex justify-end gap-3 pt-1">
<button @click="showModal = false"
class="px-5 py-2.5 border border-gray-200 rounded-xl text-gray-600 hover:bg-gray-50 text-sm font-medium transition">
取消
</button>
<button @click="submitFeedback()"
:disabled="submitting || !form.title.trim() || !form.content.trim()"
class="px-6 py-2.5 bg-indigo-600 text-white rounded-xl font-bold
hover:bg-indigo-700 disabled:opacity-40 text-sm shadow-sm transition">
<span x-text="submitting ? '提交中...' : '确认提交'"></span>
</button>
</div>
</div>
</div>
</div>
</div>
@endsection