Files
chatroom/resources/views/feedback/index.blade.php

481 lines
25 KiB
PHP
Raw Normal View History

{{--
文件功能:用户反馈前台独立页面(/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