Trae Assistant commited on
Commit
568f4d9
·
0 Parent(s):

feat: upgrade pmf radar studio with file upload and optimizations

Browse files
Files changed (6) hide show
  1. .gitignore +5 -0
  2. Dockerfile +18 -0
  3. README.md +45 -0
  4. app.py +29 -0
  5. requirements.txt +2 -0
  6. static/index.html +535 -0
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
4
+ .env
5
+ venv/
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 non-root user for security (Hugging Face Spaces requirement)
11
+ RUN useradd -m -u 1000 user
12
+ USER user
13
+ ENV HOME=/home/user \
14
+ PATH=/home/user/.local/bin:$PATH
15
+
16
+ WORKDIR /app
17
+
18
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: PMF Radar Studio
3
+ emoji: 🎯
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ pinned: false
8
+ short_description: 产品市场契合度雷达 - 创业项目PMF分析与诊断工具
9
+ ---
10
+
11
+ # 产品市场契合度雷达 (PMF Radar Studio)
12
+
13
+ PMF Radar Studio 是一个专为创业者、产品经理和投资人设计的辅助工具,用于量化分析和可视化展示产品的市场契合度(Product-Market Fit)。
14
+
15
+ 基于 Sean Ellis 的著名的 "40% 规则"(即如果有超过 40% 的用户表示如果不能继续使用该产品会感到“非常失望”,则产品达到了 PMF),本工具提供了一站式的调查数据录入、分析、可视化和报告生成功能。
16
+
17
+ ## 主要功能
18
+
19
+ * **数据录入**: 支持 CSV 格式粘贴或手动录入用户反馈数据。
20
+ * **PMF 指数计算**: 自动计算“非常失望”用户的比例,直观判断是否达到 PMF 标准。
21
+ * **用户画像分层**: 结合用户角色/行业信息,分析核心用户群(High Expectation Customers)。
22
+ * **关键词云分析**: 对核心用户的反馈("Why")进行词频分析,生成词云,挖掘核心价值点。
23
+ * **可视化仪表盘**: 生成专业的 PMF 分析图表(分布图、趋势图等)。
24
+ * **报告导出**: 一键生成分析报告图片,方便用于 BP(商业计划书)或团队汇报。
25
+
26
+ ## 使用场景
27
+
28
+ * **早期创业**: 验证 MVP(最小可行性产品)是否找到市场切入点。
29
+ * **融资对接**: 向投资人展示量化的市场验证结果。
30
+ * **产品迭代**: 根据核心用户的反馈优化产品路线图。
31
+
32
+ ## 技术栈
33
+
34
+ * **Backend**: Flask (Python)
35
+ * **Frontend**: Vue 3, Tailwind CSS, Chart.js, wordcloud2.js
36
+ * **Deployment**: Docker (Hugging Face Spaces compatible)
37
+
38
+ ## 本地运行
39
+
40
+ ```bash
41
+ pip install -r requirements.txt
42
+ python app.py
43
+ ```
44
+
45
+ 访问: `http://localhost:7860`
app.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, send_from_directory, request, jsonify
2
+ import os
3
+
4
+ app = Flask(__name__)
5
+
6
+ # Fix conflict between Jinja2 and Vue.js delimiters
7
+ # We will use Vue's delimiters ['${', '}'] in the frontend,
8
+ # but we also change Jinja2's just in case to avoid confusion.
9
+ app.jinja_env.variable_start_string = '[['
10
+ app.jinja_env.variable_end_string = ']]'
11
+ app.jinja_env.block_start_string = '[%'
12
+ app.jinja_env.block_end_string = '%]'
13
+
14
+ @app.route('/')
15
+ def index():
16
+ return send_from_directory('static', 'index.html')
17
+
18
+ @app.route('/health')
19
+ def health():
20
+ return "OK", 200
21
+
22
+ # Explicitly serve static files if needed (Flask does this by default from static folder, but good for explicit control)
23
+ @app.route('/static/<path:path>')
24
+ def send_static(path):
25
+ return send_from_directory('static', path)
26
+
27
+ if __name__ == '__main__':
28
+ port = int(os.environ.get('PORT', 7860))
29
+ app.run(host='0.0.0.0', port=port, debug=True)
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ Flask>=3.0.0
2
+ gunicorn>=21.2.0
static/index.html ADDED
@@ -0,0 +1,535 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>PMF Radar Studio - 产品市场契合度分析雷达</title>
7
+ <!-- Tailwind CSS -->
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <!-- Vue 3 -->
10
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
11
+ <!-- Chart.js -->
12
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
13
+ <!-- WordCloud2.js -->
14
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/wordcloud2.js/1.2.2/wordcloud2.min.js"></script>
15
+ <!-- html2canvas -->
16
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
17
+ <!-- Font Awesome -->
18
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
19
+
20
+ <style>
21
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
22
+ body { font-family: 'Inter', sans-serif; background-color: #f8fafc; }
23
+ .gradient-text {
24
+ background: linear-gradient(135deg, #4f46e5, #06b6d4);
25
+ -webkit-background-clip: text;
26
+ -webkit-text-fill-color: transparent;
27
+ }
28
+ .card {
29
+ background: white;
30
+ border-radius: 1rem;
31
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
32
+ transition: all 0.3s ease;
33
+ }
34
+ .card:hover {
35
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
36
+ }
37
+ </style>
38
+ </head>
39
+ <body>
40
+ <div id="app" class="min-h-screen flex flex-col">
41
+ <!-- Header -->
42
+ <header class="bg-white border-b border-gray-200 sticky top-0 z-50">
43
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
44
+ <div class="flex items-center space-x-3">
45
+ <div class="w-10 h-10 bg-gradient-to-br from-indigo-500 to-cyan-400 rounded-lg flex items-center justify-center text-white font-bold text-xl shadow-lg">
46
+ <i class="fa-solid fa-bullseye"></i>
47
+ </div>
48
+ <span class="text-xl font-bold text-gray-900 tracking-tight">PMF Radar Studio</span>
49
+ </div>
50
+ <div class="flex items-center space-x-4">
51
+ <button @click="resetData" class="text-gray-500 hover:text-red-500 transition-colors text-sm font-medium">
52
+ <i class="fa-solid fa-rotate-right mr-1"></i> 重置数据
53
+ </button>
54
+ <button @click="exportReport" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors shadow-md flex items-center">
55
+ <i class="fa-solid fa-download mr-2"></i> 导出报告
56
+ </button>
57
+ </div>
58
+ </div>
59
+ </header>
60
+
61
+ <!-- Main Content -->
62
+ <main class="flex-grow max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full grid grid-cols-1 lg:grid-cols-12 gap-8">
63
+
64
+ <!-- Left Column: Data Input -->
65
+ <div class="lg:col-span-4 space-y-6">
66
+ <div class="card p-6">
67
+ <h2 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
68
+ <i class="fa-solid fa-database mr-2 text-indigo-500"></i> 数据录入
69
+ </h2>
70
+
71
+ <div class="mb-4">
72
+ <label class="block text-sm font-medium text-gray-700 mb-2">添加单条反馈</label>
73
+ <div class="space-y-3">
74
+ <select v-model="newEntry.role" class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border">
75
+ <option value="" disabled>选择用户角色</option>
76
+ <option v-for="role in roles" :key="role" :value="role">${ role }</option>
77
+ </select>
78
+ <select v-model="newEntry.disappointment" class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border">
79
+ <option value="" disabled>如果无法继续使用产品,您会感到?</option>
80
+ <option value="Very Disappointed">非常失望 (Very Disappointed)</option>
81
+ <option value="Somewhat Disappointed">有点失望 (Somewhat Disappointed)</option>
82
+ <option value="Not Disappointed">不失望 (Not Disappointed)</option>
83
+ <option value="N/A">不适用 (N/A)</option>
84
+ </select>
85
+ <textarea v-model="newEntry.comment" placeholder="主要原因是什么?(用于生成词云)" class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border h-20"></textarea>
86
+ <button @click="addEntry" class="w-full bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-2 rounded-lg transition-colors">
87
+ <i class="fa-solid fa-plus mr-1"></i> 添加记录
88
+ </button>
89
+ </div>
90
+ </div>
91
+
92
+ <div class="relative">
93
+ <div class="absolute inset-0 flex items-center" aria-hidden="true">
94
+ <div class="w-full border-t border-gray-200"></div>
95
+ </div>
96
+ <div class="relative flex justify-center">
97
+ <span class="px-2 bg-white text-sm text-gray-500">或者</span>
98
+ </div>
99
+ </div>
100
+
101
+ <div class="mt-4">
102
+ <label class="block text-sm font-medium text-gray-700 mb-2">批量导入 (JSON)</label>
103
+ <div class="flex flex-col space-y-2">
104
+ <!-- File Upload Button -->
105
+ <div class="flex items-center justify-center w-full">
106
+ <label for="file-upload" class="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100 transition-colors">
107
+ <div class="flex flex-col items-center justify-center pt-5 pb-6">
108
+ <i class="fa-solid fa-cloud-arrow-up text-2xl text-gray-400 mb-2"></i>
109
+ <p class="mb-2 text-sm text-gray-500"><span class="font-semibold">点击上传</span> 或拖拽文件</p>
110
+ <p class="text-xs text-gray-500">JSON 文件 (支持大文件)</p>
111
+ </div>
112
+ <input id="file-upload" type="file" class="hidden" accept=".json" @change="handleFileUpload" />
113
+ </label>
114
+ </div>
115
+
116
+ <div class="relative flex justify-center my-2">
117
+ <span class="px-2 bg-white text-xs text-gray-400">或者粘贴文本</span>
118
+ </div>
119
+
120
+ <textarea v-model="bulkJson" class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border h-24 text-xs font-mono" placeholder='[{"role": "Founder", "disappointment": "Very Disappointed", "comment": "Feature X is vital"}]'></textarea>
121
+
122
+ <button @click="importJson" class="w-full border border-indigo-500 text-indigo-600 hover:bg-indigo-50 font-medium py-2 rounded-lg transition-colors text-sm">
123
+ <i class="fa-solid fa-code mr-1"></i> 导入文本 JSON
124
+ </button>
125
+ </div>
126
+ </div>
127
+ </div>
128
+
129
+ <!-- Recent Entries List -->
130
+ <div class="card p-6 max-h-96 overflow-y-auto">
131
+ <h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">最近录入 (${ entries.length })</h3>
132
+ <div class="space-y-3">
133
+ <div v-for="(entry, index) in reversedEntries" :key="index" class="p-3 bg-gray-50 rounded-lg border border-gray-100 text-sm">
134
+ <div class="flex justify-between items-start">
135
+ <span class="font-medium text-gray-800">${ entry.role }</span>
136
+ <span :class="getBadgeClass(entry.disappointment)" class="px-2 py-0.5 rounded text-xs font-semibold">
137
+ ${ getDisappointmentLabel(entry.disappointment) }
138
+ </span>
139
+ </div>
140
+ <p class="text-gray-600 mt-1 text-xs line-clamp-2" v-if="entry.comment">"${ entry.comment }"</p>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </div>
145
+
146
+ <!-- Right Column: Analysis & Report -->
147
+ <div class="lg:col-span-8 space-y-6">
148
+ <!-- Report Canvas Area -->
149
+ <div id="report-area" class="bg-white p-8 rounded-xl shadow-lg border border-gray-100 relative overflow-hidden">
150
+ <!-- Watermark -->
151
+ <div class="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
152
+ <i class="fa-solid fa-bullseye text-9xl"></i>
153
+ </div>
154
+
155
+ <div class="flex justify-between items-end mb-8 border-b border-gray-100 pb-4">
156
+ <div>
157
+ <h1 class="text-3xl font-bold text-gray-900 mb-1">PMF 分析报告</h1>
158
+ <p class="text-gray-500 text-sm">生成时间: ${ currentDate }</p>
159
+ </div>
160
+ <div class="text-right">
161
+ <div class="text-sm text-gray-500 font-medium">PMF Score</div>
162
+ <div class="text-5xl font-extrabold" :class="pmfScore >= 40 ? 'text-green-500' : 'text-amber-500'">
163
+ ${ pmfScore }%
164
+ </div>
165
+ </div>
166
+ </div>
167
+
168
+ <!-- PMF Status Banner -->
169
+ <div class="mb-8 p-4 rounded-lg flex items-start space-x-4" :class="pmfScore >= 40 ? 'bg-green-50 border border-green-100' : 'bg-amber-50 border border-amber-100'">
170
+ <div class="flex-shrink-0 mt-1">
171
+ <i v-if="pmfScore >= 40" class="fa-solid fa-circle-check text-green-500 text-xl"></i>
172
+ <i v-else class="fa-solid fa-circle-exclamation text-amber-500 text-xl"></i>
173
+ </div>
174
+ <div>
175
+ <h3 class="font-bold text-lg" :class="pmfScore >= 40 ? 'text-green-800' : 'text-amber-800'">
176
+ ${ pmfScore >= 40 ? '已达到 Product-Market Fit' : '尚未达到 Product-Market Fit' }
177
+ </h3>
178
+ <p class="text-sm mt-1" :class="pmfScore >= 40 ? 'text-green-700' : 'text-amber-700'">
179
+ ${ pmfScore >= 40 ? '恭喜!超过 40% 的用户表示如果无法使用产品会感到非常失望。您的产品已经找到了市场切入点,可以开始考虑规模化增长。' : '当前仅有 ' + pmfScore + '% 的用户是您的核心铁杆粉丝。建议继续优化产品核心价值,直到该指标超过 40% 再进行大规模推广。' }
180
+ </p>
181
+ </div>
182
+ </div>
183
+
184
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
185
+ <!-- Chart -->
186
+ <div>
187
+ <h4 class="font-semibold text-gray-700 mb-4 text-sm uppercase tracking-wide">用户反馈分布</h4>
188
+ <div class="h-64 relative">
189
+ <canvas id="distributionChart"></canvas>
190
+ </div>
191
+ </div>
192
+ <!-- Key Metrics -->
193
+ <div class="space-y-4">
194
+ <h4 class="font-semibold text-gray-700 mb-4 text-sm uppercase tracking-wide">核心数据概览</h4>
195
+ <div class="grid grid-cols-2 gap-4">
196
+ <div class="bg-gray-50 p-4 rounded-lg text-center">
197
+ <div class="text-2xl font-bold text-gray-800">${ totalResponses }</div>
198
+ <div class="text-xs text-gray-500 uppercase mt-1">总样本量</div>
199
+ </div>
200
+ <div class="bg-indigo-50 p-4 rounded-lg text-center">
201
+ <div class="text-2xl font-bold text-indigo-600">${ veryDisappointedCount }</div>
202
+ <div class="text-xs text-indigo-500 uppercase mt-1">铁杆粉丝数</div>
203
+ </div>
204
+ </div>
205
+ <div class="bg-gray-50 p-4 rounded-lg">
206
+ <h5 class="text-xs font-semibold text-gray-500 uppercase mb-2">样本角色构成</h5>
207
+ <div class="space-y-2">
208
+ <div v-for="(count, role) in roleDistribution" :key="role" class="flex items-center text-sm">
209
+ <span class="w-24 text-gray-600 truncate">${ role }</span>
210
+ <div class="flex-grow h-2 bg-gray-200 rounded-full overflow-hidden mx-2">
211
+ <div class="h-full bg-indigo-400" :style="{ width: (count / totalResponses * 100) + '%' }"></div>
212
+ </div>
213
+ <span class="text-gray-500 text-xs">${ count }</span>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ </div>
218
+ </div>
219
+
220
+ <!-- Word Cloud -->
221
+ <div>
222
+ <h4 class="font-semibold text-gray-700 mb-4 text-sm uppercase tracking-wide">核心用户心声 ("Why?")</h4>
223
+ <div class="bg-gray-50 rounded-lg border border-gray-200 h-64 relative flex items-center justify-center overflow-hidden">
224
+ <canvas id="wordCloudCanvas" width="800" height="300"></canvas>
225
+ <div v-if="wordCloudEmpty" class="absolute inset-0 flex items-center justify-center text-gray-400 text-sm">
226
+ 暂无足够的核心用户评论数据
227
+ </div>
228
+ </div>
229
+ <p class="text-xs text-gray-400 mt-2 text-center">基于 "非常失望" 用户群体的评论分析</p>
230
+ </div>
231
+
232
+ <!-- Footer -->
233
+ <div class="mt-8 pt-4 border-t border-gray-100 flex justify-between items-center text-xs text-gray-400">
234
+ <span>Generated by PMF Radar Studio</span>
235
+ <span>https://huggingface.co/spaces/your-username/pmf-radar-studio</span>
236
+ </div>
237
+ </div>
238
+ </div>
239
+ </main>
240
+ </div>
241
+
242
+ <script>
243
+ const { createApp, ref, computed, onMounted, watch } = Vue;
244
+
245
+ createApp({
246
+ delimiters: ['${', '}'],
247
+ setup() {
248
+ // Initial Data (Enriched as per memory)
249
+ const defaultEntries = [
250
+ { role: "Founder", disappointment: "Very Disappointed", comment: "This is the only tool that visualizes my cash flow correctly." },
251
+ { role: "Product Manager", disappointment: "Very Disappointed", comment: "Saves me 10 hours a week on reporting." },
252
+ { role: "Investor", disappointment: "Somewhat Disappointed", comment: "Good but needs more integrations." },
253
+ { role: "Developer", disappointment: "Not Disappointed", comment: "I can build this myself." },
254
+ { role: "Founder", disappointment: "Very Disappointed", comment: "Crucial for my fundraising deck." },
255
+ { role: "Marketing", disappointment: "Somewhat Disappointed", comment: "UI is a bit complex." },
256
+ { role: "Founder", disappointment: "Very Disappointed", comment: "I love the visualization features." },
257
+ { role: "Product Manager", disappointment: "Very Disappointed", comment: "Essential for daily tracking." },
258
+ { role: "Sales", disappointment: "N/A", comment: "Haven't used it much." },
259
+ { role: "Founder", disappointment: "Very Disappointed", comment: "Great value for money." }
260
+ ];
261
+
262
+ const entries = ref([...defaultEntries]);
263
+ const newEntry = ref({ role: "", disappointment: "", comment: "" });
264
+ const bulkJson = ref("");
265
+ const roles = ["Founder", "Product Manager", "Developer", "Designer", "Marketing", "Sales", "Investor", "Other"];
266
+
267
+ let chartInstance = null;
268
+
269
+ // Computed
270
+ const totalResponses = computed(() => entries.value.length);
271
+
272
+ const veryDisappointedCount = computed(() =>
273
+ entries.value.filter(e => e.disappointment === "Very Disappointed").length
274
+ );
275
+
276
+ const pmfScore = computed(() => {
277
+ if (totalResponses.value === 0) return 0;
278
+ return Math.round((veryDisappointedCount.value / totalResponses.value) * 100);
279
+ });
280
+
281
+ const reversedEntries = computed(() => [...entries.value].reverse().slice(0, 50)); // Show last 50
282
+
283
+ const roleDistribution = computed(() => {
284
+ const dist = {};
285
+ entries.value.forEach(e => {
286
+ dist[e.role] = (dist[e.role] || 0) + 1;
287
+ });
288
+ return dist;
289
+ });
290
+
291
+ const currentDate = computed(() => new Date().toLocaleDateString('zh-CN'));
292
+ const wordCloudEmpty = ref(false);
293
+
294
+ // Methods
295
+ const addEntry = () => {
296
+ if (!newEntry.value.role || !newEntry.value.disappointment) {
297
+ alert("请填写角色和失望程度");
298
+ return;
299
+ }
300
+ entries.value.push({ ...newEntry.value });
301
+ newEntry.value = { role: "", disappointment: "", comment: "" };
302
+ updateVisuals();
303
+ };
304
+
305
+ const handleFileUpload = (event) => {
306
+ const file = event.target.files[0];
307
+ if (!file) return;
308
+
309
+ const reader = new FileReader();
310
+ reader.onload = (e) => {
311
+ try {
312
+ const json = e.target.result;
313
+ const data = JSON.parse(json);
314
+ if (Array.isArray(data)) {
315
+ entries.value = [...entries.value, ...data];
316
+ updateVisuals();
317
+ alert(`成功导入 ${data.length} 条数据`);
318
+ } else {
319
+ alert("JSON 格式错误: 必须是数组");
320
+ }
321
+ } catch (error) {
322
+ alert("文件解析失败: " + error.message);
323
+ }
324
+ event.target.value = '';
325
+ };
326
+ reader.readAsText(file);
327
+ };
328
+
329
+ const importJson = () => {
330
+ try {
331
+ const data = JSON.parse(bulkJson.value);
332
+ if (Array.isArray(data)) {
333
+ entries.value = [...entries.value, ...data];
334
+ bulkJson.value = "";
335
+ updateVisuals();
336
+ alert(`成功导入 ${data.length} 条数据`);
337
+ } else {
338
+ alert("JSON 格式错误: 必须是数组");
339
+ }
340
+ } catch (e) {
341
+ alert("JSON 解析失败: " + e.message);
342
+ }
343
+ };
344
+
345
+ const resetData = () => {
346
+ if(confirm("确定要清空所有数据吗?")) {
347
+ entries.value = [];
348
+ updateVisuals();
349
+ }
350
+ };
351
+
352
+ const getDisappointmentLabel = (val) => {
353
+ const map = {
354
+ "Very Disappointed": "非常失望",
355
+ "Somewhat Disappointed": "有点失望",
356
+ "Not Disappointed": "不失望",
357
+ "N/A": "不适用"
358
+ };
359
+ return map[val] || val;
360
+ };
361
+
362
+ const getBadgeClass = (val) => {
363
+ const map = {
364
+ "Very Disappointed": "bg-green-100 text-green-800",
365
+ "Somewhat Disappointed": "bg-yellow-100 text-yellow-800",
366
+ "Not Disappointed": "bg-red-100 text-red-800",
367
+ "N/A": "bg-gray-100 text-gray-800"
368
+ };
369
+ return map[val] || "bg-gray-100 text-gray-800";
370
+ };
371
+
372
+ const updateChart = () => {
373
+ const ctx = document.getElementById('distributionChart').getContext('2d');
374
+
375
+ const counts = {
376
+ "Very Disappointed": 0,
377
+ "Somewhat Disappointed": 0,
378
+ "Not Disappointed": 0,
379
+ "N/A": 0
380
+ };
381
+
382
+ entries.value.forEach(e => counts[e.disappointment] = (counts[e.disappointment] || 0) + 1);
383
+
384
+ const data = [
385
+ counts["Very Disappointed"],
386
+ counts["Somewhat Disappointed"],
387
+ counts["Not Disappointed"],
388
+ counts["N/A"]
389
+ ];
390
+
391
+ if (chartInstance) {
392
+ chartInstance.destroy();
393
+ }
394
+
395
+ chartInstance = new Chart(ctx, {
396
+ type: 'bar',
397
+ data: {
398
+ labels: ['非常失望', '有点失望', '不失望', '不适用'],
399
+ datasets: [{
400
+ label: '用户数量',
401
+ data: data,
402
+ backgroundColor: [
403
+ 'rgba(34, 197, 94, 0.7)', // Green
404
+ 'rgba(251, 191, 36, 0.7)', // Amber
405
+ 'rgba(239, 68, 68, 0.7)', // Red
406
+ 'rgba(156, 163, 175, 0.7)' // Gray
407
+ ],
408
+ borderColor: [
409
+ 'rgb(34, 197, 94)',
410
+ 'rgb(251, 191, 36)',
411
+ 'rgb(239, 68, 68)',
412
+ 'rgb(156, 163, 175)'
413
+ ],
414
+ borderWidth: 1
415
+ }]
416
+ },
417
+ options: {
418
+ responsive: true,
419
+ maintainAspectRatio: false,
420
+ plugins: {
421
+ legend: { display: false }
422
+ },
423
+ scales: {
424
+ y: { beginAtZero: true, ticks: { precision: 0 } }
425
+ }
426
+ }
427
+ });
428
+ };
429
+
430
+ const updateWordCloud = () => {
431
+ const canvas = document.getElementById('wordCloudCanvas');
432
+ // Filter comments from "Very Disappointed" users
433
+ const comments = entries.value
434
+ .filter(e => e.disappointment === "Very Disappointed" && e.comment)
435
+ .map(e => e.comment)
436
+ .join(" ");
437
+
438
+ if (!comments.trim()) {
439
+ wordCloudEmpty.value = true;
440
+ // Clear canvas
441
+ const ctx = canvas.getContext('2d');
442
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
443
+ return;
444
+ }
445
+
446
+ wordCloudEmpty.value = false;
447
+
448
+ // Smart Segmentation (Chinese support)
449
+ let words = [];
450
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
451
+ const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
452
+ const segments = segmenter.segment(comments);
453
+ for (const { segment, isWordLike } of segments) {
454
+ if (isWordLike && segment.length > 1) {
455
+ words.push(segment.toLowerCase());
456
+ }
457
+ }
458
+ } else {
459
+ // Fallback: preserve Chinese characters and alphanumeric
460
+ words = comments.toLowerCase().replace(/[^\w\s\u4e00-\u9fa5]/gi, '').split(/\s+/);
461
+ }
462
+
463
+ const freq = {};
464
+ const stopWords = ["the", "and", "is", "it", "to", "for", "of", "a", "in", "this", "my", "i", "very", "so",
465
+ "的", "了", "在", "是", "我", "有", "和", "就", "不", "人", "都", "一", "一个", "上", "也", "很", "到", "说", "要", "去", "你", "会", "着", "没有", "如果", "我们", "但是", "因为", "所以"];
466
+
467
+ words.forEach(w => {
468
+ if (w.trim().length > 1 && !stopWords.includes(w)) {
469
+ freq[w] = (freq[w] || 0) + 1;
470
+ }
471
+ });
472
+
473
+ const list = Object.entries(freq).map(([word, count]) => [word, count * 10]).sort((a, b) => b[1] - a[1]).slice(0, 50);
474
+
475
+ WordCloud(canvas, {
476
+ list: list,
477
+ gridSize: 8,
478
+ weightFactor: (size) => Math.pow(size, 0.8) * 3,
479
+ fontFamily: 'Inter, "Microsoft YaHei", sans-serif',
480
+ color: 'random-dark',
481
+ backgroundColor: 'transparent',
482
+ rotateRatio: 0.5
483
+ });
484
+ };
485
+
486
+ const updateVisuals = () => {
487
+ // Use nextTick equivalent or small timeout to ensure DOM is ready
488
+ setTimeout(() => {
489
+ updateChart();
490
+ updateWordCloud();
491
+ }, 100);
492
+ };
493
+
494
+ const exportReport = () => {
495
+ const element = document.getElementById('report-area');
496
+ html2canvas(element, {
497
+ scale: 2,
498
+ backgroundColor: '#ffffff'
499
+ }).then(canvas => {
500
+ const link = document.createElement('a');
501
+ link.download = `PMF-Report-${new Date().toISOString().slice(0,10)}.png`;
502
+ link.href = canvas.toDataURL();
503
+ link.click();
504
+ });
505
+ };
506
+
507
+ onMounted(() => {
508
+ updateVisuals();
509
+ });
510
+
511
+ return {
512
+ entries,
513
+ newEntry,
514
+ bulkJson,
515
+ roles,
516
+ addEntry,
517
+ importJson,
518
+ resetData,
519
+ reversedEntries,
520
+ pmfScore,
521
+ totalResponses,
522
+ veryDisappointedCount,
523
+ roleDistribution,
524
+ currentDate,
525
+ getDisappointmentLabel,
526
+ getBadgeClass,
527
+ exportReport,
528
+ wordCloudEmpty,
529
+ handleFileUpload
530
+ };
531
+ }
532
+ }).mount('#app');
533
+ </script>
534
+ </body>
535
+ </html>