Spaces:
Sleeping
Sleeping
Commit ·
40ff5dd
0
Parent(s):
Initial commit of Feature Request Hub
Browse files- .gitignore +6 -0
- Dockerfile +20 -0
- README.md +71 -0
- app.py +86 -0
- requirements.txt +2 -0
- templates/index.html +347 -0
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
requests.json
|
| 4 |
+
.DS_Store
|
| 5 |
+
.venv/
|
| 6 |
+
env/
|
Dockerfile
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.9-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
# Create a writable directory for data persistence (if mounted)
|
| 11 |
+
# In HF Spaces, we can write to /app, but for persistence usually a dataset is better.
|
| 12 |
+
# For this demo, local file is fine (it resets on restart unless persistent storage is configured in HF).
|
| 13 |
+
RUN chmod -R 777 /app
|
| 14 |
+
|
| 15 |
+
# Create an empty data file if it doesn't exist and make it writable
|
| 16 |
+
RUN echo "[]" > requests.json && chmod 666 requests.json
|
| 17 |
+
|
| 18 |
+
EXPOSE 7860
|
| 19 |
+
|
| 20 |
+
CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
|
README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Feature Request Hub
|
| 3 |
+
emoji: 💡
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# 产品需求反馈中心 (Feature Request Hub)
|
| 11 |
+
|
| 12 |
+
一个简洁、现代化的产品需求收集与反馈平台。帮助独立开发者、创业团队收集用户建议,展示开发路线图。
|
| 13 |
+
|
| 14 |
+
## ✨ 特性
|
| 15 |
+
|
| 16 |
+
- **💡 需求提交**:用户可以提交功能建议、Bug 反馈等。
|
| 17 |
+
- **👍 投票机制**:用户可以对感兴趣的功能进行投票(Upvote),帮助你排定优先级。
|
| 18 |
+
- **📊 状态追踪**:清晰展示需求状态(待审核、计划中、开发中、已完成)。
|
| 19 |
+
- **🛡️ 管理员模式**:内置简单的管理员开关,用于更新需求状态。
|
| 20 |
+
- **🎨 现代化 UI**:基于 Vue 3 + Tailwind CSS 构建,简洁美观。
|
| 21 |
+
- **🚀 零配置部署**:基于 Docker,一键部署到 Hugging Face Spaces。
|
| 22 |
+
|
| 23 |
+
## 🛠️ 技术栈
|
| 24 |
+
|
| 25 |
+
- **后端**:Python / Flask
|
| 26 |
+
- **前端**:Vue 3 (CDN) + Tailwind CSS
|
| 27 |
+
- **存储**:本地 JSON 文件 (轻量级)
|
| 28 |
+
|
| 29 |
+
## 🚀 快速开始
|
| 30 |
+
|
| 31 |
+
### 本地运行
|
| 32 |
+
|
| 33 |
+
1. 克隆项目:
|
| 34 |
+
```bash
|
| 35 |
+
git clone https://github.com/yourusername/feature-request-hub.git
|
| 36 |
+
cd feature-request-hub
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
2. 安装依赖:
|
| 40 |
+
```bash
|
| 41 |
+
pip install -r requirements.txt
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
3. 运行:
|
| 45 |
+
```bash
|
| 46 |
+
python app.py
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
4. 打开浏览器访问 `http://localhost:7860`
|
| 50 |
+
|
| 51 |
+
### 部署到 Hugging Face Spaces
|
| 52 |
+
|
| 53 |
+
本项目已配置 Dockerfile,可直接部署。
|
| 54 |
+
|
| 55 |
+
1. 在 Hugging Face 创建一个新的 Space。
|
| 56 |
+
2. 选择 Docker SDK。
|
| 57 |
+
3. 上传本项目所有文件。
|
| 58 |
+
|
| 59 |
+
## 📝 使用说明
|
| 60 |
+
|
| 61 |
+
- **提交需求**:点击右上角 "提交新需求"。
|
| 62 |
+
- **投票**:点击列表左侧的箭头图标。
|
| 63 |
+
- **管理员模式**:在左侧边栏打开 "管理员模式" 开关,即可看到每个卡片下方的状态切换按钮。
|
| 64 |
+
|
| 65 |
+
## 🤝 贡献
|
| 66 |
+
|
| 67 |
+
欢迎提交 Issue 和 PR!
|
| 68 |
+
|
| 69 |
+
## 📄 许可证
|
| 70 |
+
|
| 71 |
+
MIT License
|
app.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from flask import Flask, render_template, request, jsonify
|
| 6 |
+
|
| 7 |
+
app = Flask(__name__)
|
| 8 |
+
DATA_FILE = "requests.json"
|
| 9 |
+
|
| 10 |
+
def load_data():
|
| 11 |
+
if not os.path.exists(DATA_FILE):
|
| 12 |
+
return []
|
| 13 |
+
try:
|
| 14 |
+
with open(DATA_FILE, "r", encoding="utf-8") as f:
|
| 15 |
+
return json.load(f)
|
| 16 |
+
except Exception:
|
| 17 |
+
return []
|
| 18 |
+
|
| 19 |
+
def save_data(data):
|
| 20 |
+
with open(DATA_FILE, "w", encoding="utf-8") as f:
|
| 21 |
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
| 22 |
+
|
| 23 |
+
@app.route("/")
|
| 24 |
+
def index():
|
| 25 |
+
return render_template("index.html")
|
| 26 |
+
|
| 27 |
+
@app.route("/api/requests", methods=["GET"])
|
| 28 |
+
def get_requests():
|
| 29 |
+
data = load_data()
|
| 30 |
+
# Sort by votes (desc) then date (desc)
|
| 31 |
+
data.sort(key=lambda x: (x.get("votes", 0), x.get("created_at", "")), reverse=True)
|
| 32 |
+
return jsonify(data)
|
| 33 |
+
|
| 34 |
+
@app.route("/api/requests", methods=["POST"])
|
| 35 |
+
def add_request():
|
| 36 |
+
data = load_data()
|
| 37 |
+
payload = request.json
|
| 38 |
+
|
| 39 |
+
if not payload.get("title") or not payload.get("description"):
|
| 40 |
+
return jsonify({"error": "Title and description are required"}), 400
|
| 41 |
+
|
| 42 |
+
new_request = {
|
| 43 |
+
"id": str(uuid.uuid4()),
|
| 44 |
+
"title": payload["title"],
|
| 45 |
+
"description": payload["description"],
|
| 46 |
+
"category": payload.get("category", "General"),
|
| 47 |
+
"votes": 1,
|
| 48 |
+
"status": "pending", # pending, planned, in_progress, completed, rejected
|
| 49 |
+
"created_at": datetime.now().isoformat(),
|
| 50 |
+
"comments": []
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
data.append(new_request)
|
| 54 |
+
save_data(data)
|
| 55 |
+
return jsonify(new_request)
|
| 56 |
+
|
| 57 |
+
@app.route("/api/requests/<req_id>/vote", methods=["POST"])
|
| 58 |
+
def vote_request(req_id):
|
| 59 |
+
data = load_data()
|
| 60 |
+
for req in data:
|
| 61 |
+
if req["id"] == req_id:
|
| 62 |
+
req["votes"] = req.get("votes", 0) + 1
|
| 63 |
+
save_data(data)
|
| 64 |
+
return jsonify({"success": True, "votes": req["votes"]})
|
| 65 |
+
return jsonify({"error": "Request not found"}), 404
|
| 66 |
+
|
| 67 |
+
@app.route("/api/requests/<req_id>/status", methods=["POST"])
|
| 68 |
+
def update_status(req_id):
|
| 69 |
+
# Simple admin check (in a real app, use auth)
|
| 70 |
+
# Here we just trust the client for the demo/MVP purpose
|
| 71 |
+
payload = request.json
|
| 72 |
+
new_status = payload.get("status")
|
| 73 |
+
|
| 74 |
+
if new_status not in ["pending", "planned", "in_progress", "completed", "rejected"]:
|
| 75 |
+
return jsonify({"error": "Invalid status"}), 400
|
| 76 |
+
|
| 77 |
+
data = load_data()
|
| 78 |
+
for req in data:
|
| 79 |
+
if req["id"] == req_id:
|
| 80 |
+
req["status"] = new_status
|
| 81 |
+
save_data(data)
|
| 82 |
+
return jsonify({"success": True, "status": new_status})
|
| 83 |
+
return jsonify({"error": "Request not found"}), 404
|
| 84 |
+
|
| 85 |
+
if __name__ == "__main__":
|
| 86 |
+
app.run(host="0.0.0.0", port=7860)
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask==3.0.0
|
| 2 |
+
gunicorn==21.2.0
|
templates/index.html
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>产品需求反馈中心 | Feature Request Hub</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
| 9 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 10 |
+
<style>
|
| 11 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
| 12 |
+
body { font-family: 'Inter', sans-serif; }
|
| 13 |
+
.vote-btn:hover { transform: translateY(-2px); }
|
| 14 |
+
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
|
| 15 |
+
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
| 16 |
+
/* Custom scrollbar */
|
| 17 |
+
::-webkit-scrollbar { width: 6px; }
|
| 18 |
+
::-webkit-scrollbar-track { background: #f1f1f1; }
|
| 19 |
+
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
| 20 |
+
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
| 21 |
+
</style>
|
| 22 |
+
</head>
|
| 23 |
+
<body class="bg-slate-50 text-slate-800">
|
| 24 |
+
<div id="app" class="min-h-screen flex flex-col">
|
| 25 |
+
<!-- Header -->
|
| 26 |
+
<header class="bg-white border-b border-slate-200 sticky top-0 z-10">
|
| 27 |
+
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
| 28 |
+
<div class="flex items-center gap-3">
|
| 29 |
+
<div class="bg-indigo-600 text-white p-2 rounded-lg">
|
| 30 |
+
<i class="fa-solid fa-lightbulb"></i>
|
| 31 |
+
</div>
|
| 32 |
+
<h1 class="font-bold text-xl tracking-tight text-slate-900">需求反馈中心</h1>
|
| 33 |
+
</div>
|
| 34 |
+
<div class="flex items-center gap-4">
|
| 35 |
+
<button @click="openModal" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm flex items-center gap-2">
|
| 36 |
+
<i class="fa-solid fa-plus"></i> 提交新需求
|
| 37 |
+
</button>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
</header>
|
| 41 |
+
|
| 42 |
+
<!-- Main Content -->
|
| 43 |
+
<main class="flex-1 max-w-5xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 44 |
+
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
| 45 |
+
<!-- Sidebar -->
|
| 46 |
+
<div class="md:col-span-1 space-y-6">
|
| 47 |
+
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4">
|
| 48 |
+
<h3 class="font-semibold text-slate-900 mb-3 text-sm uppercase tracking-wider">状态概览</h3>
|
| 49 |
+
<div class="space-y-2">
|
| 50 |
+
<div v-for="(count, status) in statusCounts" :key="status"
|
| 51 |
+
class="flex items-center justify-between text-sm p-2 rounded hover:bg-slate-50 cursor-pointer"
|
| 52 |
+
@click="filterStatus = status"
|
| 53 |
+
:class="{'bg-indigo-50 text-indigo-700': filterStatus === status}">
|
| 54 |
+
<div class="flex items-center gap-2">
|
| 55 |
+
<span class="w-2 h-2 rounded-full" :class="statusColor(status)"></span>
|
| 56 |
+
<span class="capitalize">[[ statusLabel(status) ]]</span>
|
| 57 |
+
</div>
|
| 58 |
+
<span class="bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full text-xs font-medium">[[ count ]]</span>
|
| 59 |
+
</div>
|
| 60 |
+
<div class="flex items-center justify-between text-sm p-2 rounded hover:bg-slate-50 cursor-pointer"
|
| 61 |
+
@click="filterStatus = 'all'"
|
| 62 |
+
:class="{'bg-indigo-50 text-indigo-700': filterStatus === 'all'}">
|
| 63 |
+
<div class="flex items-center gap-2">
|
| 64 |
+
<span class="w-2 h-2 rounded-full bg-slate-400"></span>
|
| 65 |
+
<span>全部</span>
|
| 66 |
+
</div>
|
| 67 |
+
<span class="bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full text-xs font-medium">[[ requests.length ]]</span>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div class="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow-lg p-5 text-white">
|
| 73 |
+
<h3 class="font-bold text-lg mb-1">管理员模式</h3>
|
| 74 |
+
<p class="text-indigo-100 text-xs mb-3">切换以管理需求状态</p>
|
| 75 |
+
<label class="relative inline-flex items-center cursor-pointer">
|
| 76 |
+
<input type="checkbox" v-model="isAdmin" class="sr-only peer">
|
| 77 |
+
<div class="w-11 h-6 bg-indigo-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-white/30"></div>
|
| 78 |
+
<span class="ml-3 text-sm font-medium">[[ isAdmin ? '已开启' : '已关闭' ]]</span>
|
| 79 |
+
</label>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<!-- Feed -->
|
| 84 |
+
<div class="md:col-span-3 space-y-4">
|
| 85 |
+
<!-- Loading -->
|
| 86 |
+
<div v-if="loading" class="flex justify-center py-12">
|
| 87 |
+
<i class="fa-solid fa-circle-notch fa-spin text-indigo-500 text-2xl"></i>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<!-- Empty State -->
|
| 91 |
+
<div v-else-if="filteredRequests.length === 0" class="text-center py-12 bg-white rounded-xl border border-dashed border-slate-300">
|
| 92 |
+
<div class="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-4">
|
| 93 |
+
<i class="fa-regular fa-folder-open text-slate-400 text-2xl"></i>
|
| 94 |
+
</div>
|
| 95 |
+
<h3 class="text-lg font-medium text-slate-900">暂无相关需求</h3>
|
| 96 |
+
<p class="text-slate-500 mt-1">成为第一个提出建议的人吧!</p>
|
| 97 |
+
<button @click="openModal" class="mt-4 text-indigo-600 hover:text-indigo-700 font-medium text-sm">提交需求 →</button>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<!-- List -->
|
| 101 |
+
<div v-else v-for="req in filteredRequests" :key="req.id" class="bg-white rounded-xl p-5 shadow-sm border border-slate-100 hover:shadow-md transition-shadow group">
|
| 102 |
+
<div class="flex gap-5">
|
| 103 |
+
<!-- Vote Button -->
|
| 104 |
+
<button @click="vote(req.id)" class="vote-btn flex flex-col items-center justify-center w-12 h-14 rounded-lg border border-slate-200 bg-slate-50 hover:border-indigo-300 hover:bg-indigo-50 transition-all group/vote flex-shrink-0" :class="{'border-indigo-500 bg-indigo-50 text-indigo-600': hasVoted(req.id)}">
|
| 105 |
+
<i class="fa-solid fa-caret-up text-slate-400 group-hover/vote:text-indigo-500" :class="{'text-indigo-600': hasVoted(req.id)}"></i>
|
| 106 |
+
<span class="font-bold text-sm" :class="{'text-indigo-700': hasVoted(req.id), 'text-slate-600': !hasVoted(req.id)}">[[ req.votes ]]</span>
|
| 107 |
+
</button>
|
| 108 |
+
|
| 109 |
+
<!-- Content -->
|
| 110 |
+
<div class="flex-1">
|
| 111 |
+
<div class="flex items-start justify-between mb-1">
|
| 112 |
+
<h3 class="font-bold text-slate-900 text-lg leading-snug">[[ req.title ]]</h3>
|
| 113 |
+
<span :class="['px-2.5 py-1 rounded-full text-xs font-semibold capitalize whitespace-nowrap ml-2', statusClass(req.status)]">
|
| 114 |
+
[[ statusLabel(req.status) ]]
|
| 115 |
+
</span>
|
| 116 |
+
</div>
|
| 117 |
+
<p class="text-slate-600 text-sm leading-relaxed mb-3">[[ req.description ]]</p>
|
| 118 |
+
<div class="flex items-center gap-4 text-xs text-slate-400">
|
| 119 |
+
<span class="flex items-center gap-1">
|
| 120 |
+
<i class="fa-regular fa-clock"></i> [[ formatDate(req.created_at) ]]
|
| 121 |
+
</span>
|
| 122 |
+
<span class="bg-slate-100 px-2 py-0.5 rounded text-slate-500">[[ req.category ]]</span>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<!-- Admin Controls -->
|
| 126 |
+
<div v-if="isAdmin" class="mt-4 pt-3 border-t border-slate-100 flex gap-2 overflow-x-auto pb-1">
|
| 127 |
+
<button v-for="s in adminStatuses" @click="updateStatus(req.id, s.key)"
|
| 128 |
+
class="px-2 py-1 rounded text-xs border transition-colors whitespace-nowrap"
|
| 129 |
+
:class="req.status === s.key ? 'bg-slate-800 text-white border-slate-800' : 'bg-white text-slate-600 border-slate-200 hover:border-slate-400'">
|
| 130 |
+
设为: [[ s.label ]]
|
| 131 |
+
</button>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
</main>
|
| 139 |
+
|
| 140 |
+
<!-- Modal -->
|
| 141 |
+
<div v-if="showModal" class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-50 flex items-center justify-center p-4 fade-enter-active">
|
| 142 |
+
<div class="bg-white rounded-2xl shadow-xl max-w-lg w-full overflow-hidden transform transition-all">
|
| 143 |
+
<div class="bg-slate-50 px-6 py-4 border-b border-slate-100 flex justify-between items-center">
|
| 144 |
+
<h3 class="font-bold text-lg text-slate-900">提交新需求</h3>
|
| 145 |
+
<button @click="showModal = false" class="text-slate-400 hover:text-slate-600">
|
| 146 |
+
<i class="fa-solid fa-times"></i>
|
| 147 |
+
</button>
|
| 148 |
+
</div>
|
| 149 |
+
<div class="p-6 space-y-4">
|
| 150 |
+
<div>
|
| 151 |
+
<label class="block text-sm font-medium text-slate-700 mb-1">标题</label>
|
| 152 |
+
<input v-model="newRequest.title" type="text" placeholder="简短描述你的建议..." class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-shadow">
|
| 153 |
+
</div>
|
| 154 |
+
<div>
|
| 155 |
+
<label class="block text-sm font-medium text-slate-700 mb-1">类别</label>
|
| 156 |
+
<select v-model="newRequest.category" class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-shadow bg-white">
|
| 157 |
+
<option>功能建议 (Feature)</option>
|
| 158 |
+
<option>Bug 修复 (Bug)</option>
|
| 159 |
+
<option>性能优化 (Performance)</option>
|
| 160 |
+
<option>界面设计 (UI/UX)</option>
|
| 161 |
+
<option>其他 (Other)</option>
|
| 162 |
+
</select>
|
| 163 |
+
</div>
|
| 164 |
+
<div>
|
| 165 |
+
<label class="block text-sm font-medium text-slate-700 mb-1">详细描述</label>
|
| 166 |
+
<textarea v-model="newRequest.description" rows="4" placeholder="请详细描述该功能如何运作,以及它解决的问题..." class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-shadow resize-none"></textarea>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
<div class="bg-slate-50 px-6 py-4 flex justify-end gap-3">
|
| 170 |
+
<button @click="showModal = false" class="px-4 py-2 text-slate-600 hover:text-slate-800 font-medium text-sm">取消</button>
|
| 171 |
+
<button @click="submitRequest" :disabled="!isValid" class="bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white px-5 py-2 rounded-lg font-medium text-sm shadow-sm transition-colors flex items-center gap-2">
|
| 172 |
+
<span v-if="submitting"><i class="fa-solid fa-spinner fa-spin"></i> 提交中...</span>
|
| 173 |
+
<span v-else>提交反馈</span>
|
| 174 |
+
</button>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
<script>
|
| 181 |
+
const { createApp, ref, computed, onMounted } = Vue;
|
| 182 |
+
|
| 183 |
+
createApp({
|
| 184 |
+
delimiters: ['[[', ']]'],
|
| 185 |
+
setup() {
|
| 186 |
+
const requests = ref([]);
|
| 187 |
+
const loading = ref(true);
|
| 188 |
+
const showModal = ref(false);
|
| 189 |
+
const submitting = ref(false);
|
| 190 |
+
const isAdmin = ref(false);
|
| 191 |
+
const filterStatus = ref('all');
|
| 192 |
+
|
| 193 |
+
const newRequest = ref({
|
| 194 |
+
title: '',
|
| 195 |
+
category: '功能建议 (Feature)',
|
| 196 |
+
description: ''
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
const adminStatuses = [
|
| 200 |
+
{ key: 'pending', label: '待审核' },
|
| 201 |
+
{ key: 'planned', label: '计划中' },
|
| 202 |
+
{ key: 'in_progress', label: '开发中' },
|
| 203 |
+
{ key: 'completed', label: '已完成' },
|
| 204 |
+
{ key: 'rejected', label: '已拒绝' }
|
| 205 |
+
];
|
| 206 |
+
|
| 207 |
+
const votedIds = ref(new Set());
|
| 208 |
+
|
| 209 |
+
const fetchRequests = async () => {
|
| 210 |
+
loading.value = true;
|
| 211 |
+
try {
|
| 212 |
+
const res = await fetch('/api/requests');
|
| 213 |
+
requests.value = await res.json();
|
| 214 |
+
} catch (e) {
|
| 215 |
+
console.error(e);
|
| 216 |
+
} finally {
|
| 217 |
+
loading.value = false;
|
| 218 |
+
}
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
+
const isValid = computed(() => {
|
| 222 |
+
return newRequest.value.title.trim() && newRequest.value.description.trim();
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
const submitRequest = async () => {
|
| 226 |
+
if (!isValid.value) return;
|
| 227 |
+
submitting.value = true;
|
| 228 |
+
try {
|
| 229 |
+
const res = await fetch('/api/requests', {
|
| 230 |
+
method: 'POST',
|
| 231 |
+
headers: { 'Content-Type': 'application/json' },
|
| 232 |
+
body: JSON.stringify(newRequest.value)
|
| 233 |
+
});
|
| 234 |
+
if (res.ok) {
|
| 235 |
+
showModal.value = false;
|
| 236 |
+
newRequest.value = { title: '', category: '功能建议 (Feature)', description: '' };
|
| 237 |
+
await fetchRequests();
|
| 238 |
+
}
|
| 239 |
+
} finally {
|
| 240 |
+
submitting.value = false;
|
| 241 |
+
}
|
| 242 |
+
};
|
| 243 |
+
|
| 244 |
+
const vote = async (id) => {
|
| 245 |
+
if (hasVoted(id)) return;
|
| 246 |
+
|
| 247 |
+
// Optimistic update
|
| 248 |
+
const req = requests.value.find(r => r.id === id);
|
| 249 |
+
if (req) req.votes++;
|
| 250 |
+
|
| 251 |
+
votedIds.value.add(id);
|
| 252 |
+
localStorage.setItem('voted_ids', JSON.stringify([...votedIds.value]));
|
| 253 |
+
|
| 254 |
+
try {
|
| 255 |
+
await fetch(`/api/requests/${id}/vote`, { method: 'POST' });
|
| 256 |
+
} catch (e) {
|
| 257 |
+
// Revert if failed (simplified)
|
| 258 |
+
}
|
| 259 |
+
};
|
| 260 |
+
|
| 261 |
+
const updateStatus = async (id, status) => {
|
| 262 |
+
const req = requests.value.find(r => r.id === id);
|
| 263 |
+
if (req) req.status = status;
|
| 264 |
+
|
| 265 |
+
await fetch(`/api/requests/${id}/status`, {
|
| 266 |
+
method: 'POST',
|
| 267 |
+
headers: { 'Content-Type': 'application/json' },
|
| 268 |
+
body: JSON.stringify({ status })
|
| 269 |
+
});
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
const hasVoted = (id) => votedIds.value.has(id);
|
| 273 |
+
|
| 274 |
+
const openModal = () => showModal.value = true;
|
| 275 |
+
|
| 276 |
+
const statusLabel = (status) => {
|
| 277 |
+
const map = {
|
| 278 |
+
'pending': '待审核',
|
| 279 |
+
'planned': '计划中',
|
| 280 |
+
'in_progress': '开发中',
|
| 281 |
+
'completed': '已完成',
|
| 282 |
+
'rejected': '已拒绝'
|
| 283 |
+
};
|
| 284 |
+
return map[status] || status;
|
| 285 |
+
};
|
| 286 |
+
|
| 287 |
+
const statusColor = (status) => {
|
| 288 |
+
const map = {
|
| 289 |
+
'pending': 'bg-slate-400',
|
| 290 |
+
'planned': 'bg-blue-500',
|
| 291 |
+
'in_progress': 'bg-amber-500',
|
| 292 |
+
'completed': 'bg-emerald-500',
|
| 293 |
+
'rejected': 'bg-red-500'
|
| 294 |
+
};
|
| 295 |
+
return map[status] || 'bg-slate-400';
|
| 296 |
+
};
|
| 297 |
+
|
| 298 |
+
const statusClass = (status) => {
|
| 299 |
+
const map = {
|
| 300 |
+
'pending': 'bg-slate-100 text-slate-600',
|
| 301 |
+
'planned': 'bg-blue-100 text-blue-700',
|
| 302 |
+
'in_progress': 'bg-amber-100 text-amber-700',
|
| 303 |
+
'completed': 'bg-emerald-100 text-emerald-700',
|
| 304 |
+
'rejected': 'bg-red-100 text-red-700'
|
| 305 |
+
};
|
| 306 |
+
return map[status] || 'bg-slate-100';
|
| 307 |
+
};
|
| 308 |
+
|
| 309 |
+
const formatDate = (isoString) => {
|
| 310 |
+
return new Date(isoString).toLocaleDateString('zh-CN');
|
| 311 |
+
};
|
| 312 |
+
|
| 313 |
+
const filteredRequests = computed(() => {
|
| 314 |
+
if (filterStatus.value === 'all') return requests.value;
|
| 315 |
+
return requests.value.filter(r => r.status === filterStatus.value);
|
| 316 |
+
});
|
| 317 |
+
|
| 318 |
+
const statusCounts = computed(() => {
|
| 319 |
+
const counts = {
|
| 320 |
+
'pending': 0, 'planned': 0, 'in_progress': 0, 'completed': 0, 'rejected': 0
|
| 321 |
+
};
|
| 322 |
+
requests.value.forEach(r => {
|
| 323 |
+
if (counts[r.status] !== undefined) counts[r.status]++;
|
| 324 |
+
});
|
| 325 |
+
return counts;
|
| 326 |
+
});
|
| 327 |
+
|
| 328 |
+
onMounted(() => {
|
| 329 |
+
fetchRequests();
|
| 330 |
+
const storedVotes = localStorage.getItem('voted_ids');
|
| 331 |
+
if (storedVotes) {
|
| 332 |
+
votedIds.value = new Set(JSON.parse(storedVotes));
|
| 333 |
+
}
|
| 334 |
+
});
|
| 335 |
+
|
| 336 |
+
return {
|
| 337 |
+
requests, loading, showModal, submitting, newRequest,
|
| 338 |
+
submitRequest, vote, hasVoted, openModal, isValid,
|
| 339 |
+
statusLabel, statusClass, statusColor, formatDate,
|
| 340 |
+
isAdmin, adminStatuses, updateStatus,
|
| 341 |
+
filterStatus, filteredRequests, statusCounts
|
| 342 |
+
};
|
| 343 |
+
}
|
| 344 |
+
}).mount('#app');
|
| 345 |
+
</script>
|
| 346 |
+
</body>
|
| 347 |
+
</html>
|