- 任命/撤销事件增加 type 字段区分类型 - 任命:全屏礼花 + 紫色弹窗 + 紫色系统消息 - 撤销:灰色弹窗 + 灰色系统消息,无礼花 - 消息分发:操作者/被操作者显示在私聊面板,其他人显示在公屏 - 系统消息加随机鼓励语(各5条轮换) - ChatStateService 修复 Redis key 前缀扫描问题(getAllActiveRoomIds) - 用户名片折叠优化:管理员视野、职务履历均可折叠 - 管理操作 + 职务操作合并为「🔧 管理操作」折叠区 - 悄悄话改为「🎁 送礼物」按钮,礼物面板内联展开
481 lines
25 KiB
PHP
481 lines
25 KiB
PHP
{{--
|
||
文件功能:用户反馈前台独立页面(/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">×</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
|