duqing2026 commited on
Commit
40ff5dd
·
0 Parent(s):

Initial commit of Feature Request Hub

Browse files
Files changed (6) hide show
  1. .gitignore +6 -0
  2. Dockerfile +20 -0
  3. README.md +71 -0
  4. app.py +86 -0
  5. requirements.txt +2 -0
  6. 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">提交需求 &rarr;</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>