pmf-radar-studio / static /index.html
Trae Assistant
feat: upgrade pmf radar studio with file upload and optimizations
568f4d9
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PMF Radar Studio - 产品市场契合度分析雷达</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Vue 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- WordCloud2.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/wordcloud2.js/1.2.2/wordcloud2.min.js"></script>
<!-- html2canvas -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body { font-family: 'Inter', sans-serif; background-color: #f8fafc; }
.gradient-text {
background: linear-gradient(135deg, #4f46e5, #06b6d4);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.card {
background: white;
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
</style>
</head>
<body>
<div id="app" class="min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-white border-b border-gray-200 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div class="flex items-center space-x-3">
<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">
<i class="fa-solid fa-bullseye"></i>
</div>
<span class="text-xl font-bold text-gray-900 tracking-tight">PMF Radar Studio</span>
</div>
<div class="flex items-center space-x-4">
<button @click="resetData" class="text-gray-500 hover:text-red-500 transition-colors text-sm font-medium">
<i class="fa-solid fa-rotate-right mr-1"></i> 重置数据
</button>
<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">
<i class="fa-solid fa-download mr-2"></i> 导出报告
</button>
</div>
</div>
</header>
<!-- Main Content -->
<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">
<!-- Left Column: Data Input -->
<div class="lg:col-span-4 space-y-6">
<div class="card p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
<i class="fa-solid fa-database mr-2 text-indigo-500"></i> 数据录入
</h2>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">添加单条反馈</label>
<div class="space-y-3">
<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">
<option value="" disabled>选择用户角色</option>
<option v-for="role in roles" :key="role" :value="role">${ role }</option>
</select>
<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">
<option value="" disabled>如果无法继续使用产品,您会感到?</option>
<option value="Very Disappointed">非常失望 (Very Disappointed)</option>
<option value="Somewhat Disappointed">有点失望 (Somewhat Disappointed)</option>
<option value="Not Disappointed">不失望 (Not Disappointed)</option>
<option value="N/A">不适用 (N/A)</option>
</select>
<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>
<button @click="addEntry" class="w-full bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-2 rounded-lg transition-colors">
<i class="fa-solid fa-plus mr-1"></i> 添加记录
</button>
</div>
</div>
<div class="relative">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t border-gray-200"></div>
</div>
<div class="relative flex justify-center">
<span class="px-2 bg-white text-sm text-gray-500">或者</span>
</div>
</div>
<div class="mt-4">
<label class="block text-sm font-medium text-gray-700 mb-2">批量导入 (JSON)</label>
<div class="flex flex-col space-y-2">
<!-- File Upload Button -->
<div class="flex items-center justify-center w-full">
<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">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<i class="fa-solid fa-cloud-arrow-up text-2xl text-gray-400 mb-2"></i>
<p class="mb-2 text-sm text-gray-500"><span class="font-semibold">点击上传</span> 或拖拽文件</p>
<p class="text-xs text-gray-500">JSON 文件 (支持大文件)</p>
</div>
<input id="file-upload" type="file" class="hidden" accept=".json" @change="handleFileUpload" />
</label>
</div>
<div class="relative flex justify-center my-2">
<span class="px-2 bg-white text-xs text-gray-400">或者粘贴文本</span>
</div>
<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>
<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">
<i class="fa-solid fa-code mr-1"></i> 导入文本 JSON
</button>
</div>
</div>
</div>
<!-- Recent Entries List -->
<div class="card p-6 max-h-96 overflow-y-auto">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">最近录入 (${ entries.length })</h3>
<div class="space-y-3">
<div v-for="(entry, index) in reversedEntries" :key="index" class="p-3 bg-gray-50 rounded-lg border border-gray-100 text-sm">
<div class="flex justify-between items-start">
<span class="font-medium text-gray-800">${ entry.role }</span>
<span :class="getBadgeClass(entry.disappointment)" class="px-2 py-0.5 rounded text-xs font-semibold">
${ getDisappointmentLabel(entry.disappointment) }
</span>
</div>
<p class="text-gray-600 mt-1 text-xs line-clamp-2" v-if="entry.comment">"${ entry.comment }"</p>
</div>
</div>
</div>
</div>
<!-- Right Column: Analysis & Report -->
<div class="lg:col-span-8 space-y-6">
<!-- Report Canvas Area -->
<div id="report-area" class="bg-white p-8 rounded-xl shadow-lg border border-gray-100 relative overflow-hidden">
<!-- Watermark -->
<div class="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
<i class="fa-solid fa-bullseye text-9xl"></i>
</div>
<div class="flex justify-between items-end mb-8 border-b border-gray-100 pb-4">
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-1">PMF 分析报告</h1>
<p class="text-gray-500 text-sm">生成时间: ${ currentDate }</p>
</div>
<div class="text-right">
<div class="text-sm text-gray-500 font-medium">PMF Score</div>
<div class="text-5xl font-extrabold" :class="pmfScore >= 40 ? 'text-green-500' : 'text-amber-500'">
${ pmfScore }%
</div>
</div>
</div>
<!-- PMF Status Banner -->
<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'">
<div class="flex-shrink-0 mt-1">
<i v-if="pmfScore >= 40" class="fa-solid fa-circle-check text-green-500 text-xl"></i>
<i v-else class="fa-solid fa-circle-exclamation text-amber-500 text-xl"></i>
</div>
<div>
<h3 class="font-bold text-lg" :class="pmfScore >= 40 ? 'text-green-800' : 'text-amber-800'">
${ pmfScore >= 40 ? '已达到 Product-Market Fit' : '尚未达到 Product-Market Fit' }
</h3>
<p class="text-sm mt-1" :class="pmfScore >= 40 ? 'text-green-700' : 'text-amber-700'">
${ pmfScore >= 40 ? '恭喜!超过 40% 的用户表示如果无法使用产品会感到非常失望。您的产品已经找到了市场切入点,可以开始考虑规模化增长。' : '当前仅有 ' + pmfScore + '% 的用户是您的核心铁杆粉丝。建议继续优化产品核心价值,直到该指标超过 40% 再进行大规模推广。' }
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<!-- Chart -->
<div>
<h4 class="font-semibold text-gray-700 mb-4 text-sm uppercase tracking-wide">用户反馈分布</h4>
<div class="h-64 relative">
<canvas id="distributionChart"></canvas>
</div>
</div>
<!-- Key Metrics -->
<div class="space-y-4">
<h4 class="font-semibold text-gray-700 mb-4 text-sm uppercase tracking-wide">核心数据概览</h4>
<div class="grid grid-cols-2 gap-4">
<div class="bg-gray-50 p-4 rounded-lg text-center">
<div class="text-2xl font-bold text-gray-800">${ totalResponses }</div>
<div class="text-xs text-gray-500 uppercase mt-1">总样本量</div>
</div>
<div class="bg-indigo-50 p-4 rounded-lg text-center">
<div class="text-2xl font-bold text-indigo-600">${ veryDisappointedCount }</div>
<div class="text-xs text-indigo-500 uppercase mt-1">铁杆粉丝数</div>
</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<h5 class="text-xs font-semibold text-gray-500 uppercase mb-2">样本角色构成</h5>
<div class="space-y-2">
<div v-for="(count, role) in roleDistribution" :key="role" class="flex items-center text-sm">
<span class="w-24 text-gray-600 truncate">${ role }</span>
<div class="flex-grow h-2 bg-gray-200 rounded-full overflow-hidden mx-2">
<div class="h-full bg-indigo-400" :style="{ width: (count / totalResponses * 100) + '%' }"></div>
</div>
<span class="text-gray-500 text-xs">${ count }</span>
</div>
</div>
</div>
</div>
</div>
<!-- Word Cloud -->
<div>
<h4 class="font-semibold text-gray-700 mb-4 text-sm uppercase tracking-wide">核心用户心声 ("Why?")</h4>
<div class="bg-gray-50 rounded-lg border border-gray-200 h-64 relative flex items-center justify-center overflow-hidden">
<canvas id="wordCloudCanvas" width="800" height="300"></canvas>
<div v-if="wordCloudEmpty" class="absolute inset-0 flex items-center justify-center text-gray-400 text-sm">
暂无足够的核心用户评论数据
</div>
</div>
<p class="text-xs text-gray-400 mt-2 text-center">基于 "非常失望" 用户群体的评论分析</p>
</div>
<!-- Footer -->
<div class="mt-8 pt-4 border-t border-gray-100 flex justify-between items-center text-xs text-gray-400">
<span>Generated by PMF Radar Studio</span>
<span>https://huggingface.co/spaces/your-username/pmf-radar-studio</span>
</div>
</div>
</div>
</main>
</div>
<script>
const { createApp, ref, computed, onMounted, watch } = Vue;
createApp({
delimiters: ['${', '}'],
setup() {
// Initial Data (Enriched as per memory)
const defaultEntries = [
{ role: "Founder", disappointment: "Very Disappointed", comment: "This is the only tool that visualizes my cash flow correctly." },
{ role: "Product Manager", disappointment: "Very Disappointed", comment: "Saves me 10 hours a week on reporting." },
{ role: "Investor", disappointment: "Somewhat Disappointed", comment: "Good but needs more integrations." },
{ role: "Developer", disappointment: "Not Disappointed", comment: "I can build this myself." },
{ role: "Founder", disappointment: "Very Disappointed", comment: "Crucial for my fundraising deck." },
{ role: "Marketing", disappointment: "Somewhat Disappointed", comment: "UI is a bit complex." },
{ role: "Founder", disappointment: "Very Disappointed", comment: "I love the visualization features." },
{ role: "Product Manager", disappointment: "Very Disappointed", comment: "Essential for daily tracking." },
{ role: "Sales", disappointment: "N/A", comment: "Haven't used it much." },
{ role: "Founder", disappointment: "Very Disappointed", comment: "Great value for money." }
];
const entries = ref([...defaultEntries]);
const newEntry = ref({ role: "", disappointment: "", comment: "" });
const bulkJson = ref("");
const roles = ["Founder", "Product Manager", "Developer", "Designer", "Marketing", "Sales", "Investor", "Other"];
let chartInstance = null;
// Computed
const totalResponses = computed(() => entries.value.length);
const veryDisappointedCount = computed(() =>
entries.value.filter(e => e.disappointment === "Very Disappointed").length
);
const pmfScore = computed(() => {
if (totalResponses.value === 0) return 0;
return Math.round((veryDisappointedCount.value / totalResponses.value) * 100);
});
const reversedEntries = computed(() => [...entries.value].reverse().slice(0, 50)); // Show last 50
const roleDistribution = computed(() => {
const dist = {};
entries.value.forEach(e => {
dist[e.role] = (dist[e.role] || 0) + 1;
});
return dist;
});
const currentDate = computed(() => new Date().toLocaleDateString('zh-CN'));
const wordCloudEmpty = ref(false);
// Methods
const addEntry = () => {
if (!newEntry.value.role || !newEntry.value.disappointment) {
alert("请填写角色和失望程度");
return;
}
entries.value.push({ ...newEntry.value });
newEntry.value = { role: "", disappointment: "", comment: "" };
updateVisuals();
};
const handleFileUpload = (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const json = e.target.result;
const data = JSON.parse(json);
if (Array.isArray(data)) {
entries.value = [...entries.value, ...data];
updateVisuals();
alert(`成功导入 ${data.length} 条数据`);
} else {
alert("JSON 格式错误: 必须是数组");
}
} catch (error) {
alert("文件解析失败: " + error.message);
}
event.target.value = '';
};
reader.readAsText(file);
};
const importJson = () => {
try {
const data = JSON.parse(bulkJson.value);
if (Array.isArray(data)) {
entries.value = [...entries.value, ...data];
bulkJson.value = "";
updateVisuals();
alert(`成功导入 ${data.length} 条数据`);
} else {
alert("JSON 格式错误: 必须是数组");
}
} catch (e) {
alert("JSON 解析失败: " + e.message);
}
};
const resetData = () => {
if(confirm("确定要清空所有数据吗?")) {
entries.value = [];
updateVisuals();
}
};
const getDisappointmentLabel = (val) => {
const map = {
"Very Disappointed": "非常失望",
"Somewhat Disappointed": "有点失望",
"Not Disappointed": "不失望",
"N/A": "不适用"
};
return map[val] || val;
};
const getBadgeClass = (val) => {
const map = {
"Very Disappointed": "bg-green-100 text-green-800",
"Somewhat Disappointed": "bg-yellow-100 text-yellow-800",
"Not Disappointed": "bg-red-100 text-red-800",
"N/A": "bg-gray-100 text-gray-800"
};
return map[val] || "bg-gray-100 text-gray-800";
};
const updateChart = () => {
const ctx = document.getElementById('distributionChart').getContext('2d');
const counts = {
"Very Disappointed": 0,
"Somewhat Disappointed": 0,
"Not Disappointed": 0,
"N/A": 0
};
entries.value.forEach(e => counts[e.disappointment] = (counts[e.disappointment] || 0) + 1);
const data = [
counts["Very Disappointed"],
counts["Somewhat Disappointed"],
counts["Not Disappointed"],
counts["N/A"]
];
if (chartInstance) {
chartInstance.destroy();
}
chartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: ['非常失望', '有点失望', '不失望', '不适用'],
datasets: [{
label: '用户数量',
data: data,
backgroundColor: [
'rgba(34, 197, 94, 0.7)', // Green
'rgba(251, 191, 36, 0.7)', // Amber
'rgba(239, 68, 68, 0.7)', // Red
'rgba(156, 163, 175, 0.7)' // Gray
],
borderColor: [
'rgb(34, 197, 94)',
'rgb(251, 191, 36)',
'rgb(239, 68, 68)',
'rgb(156, 163, 175)'
],
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: { beginAtZero: true, ticks: { precision: 0 } }
}
}
});
};
const updateWordCloud = () => {
const canvas = document.getElementById('wordCloudCanvas');
// Filter comments from "Very Disappointed" users
const comments = entries.value
.filter(e => e.disappointment === "Very Disappointed" && e.comment)
.map(e => e.comment)
.join(" ");
if (!comments.trim()) {
wordCloudEmpty.value = true;
// Clear canvas
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
return;
}
wordCloudEmpty.value = false;
// Smart Segmentation (Chinese support)
let words = [];
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
const segments = segmenter.segment(comments);
for (const { segment, isWordLike } of segments) {
if (isWordLike && segment.length > 1) {
words.push(segment.toLowerCase());
}
}
} else {
// Fallback: preserve Chinese characters and alphanumeric
words = comments.toLowerCase().replace(/[^\w\s\u4e00-\u9fa5]/gi, '').split(/\s+/);
}
const freq = {};
const stopWords = ["the", "and", "is", "it", "to", "for", "of", "a", "in", "this", "my", "i", "very", "so",
"的", "了", "在", "是", "我", "有", "和", "就", "不", "人", "都", "一", "一个", "上", "也", "很", "到", "说", "要", "去", "你", "会", "着", "没有", "如果", "我们", "但是", "因为", "所以"];
words.forEach(w => {
if (w.trim().length > 1 && !stopWords.includes(w)) {
freq[w] = (freq[w] || 0) + 1;
}
});
const list = Object.entries(freq).map(([word, count]) => [word, count * 10]).sort((a, b) => b[1] - a[1]).slice(0, 50);
WordCloud(canvas, {
list: list,
gridSize: 8,
weightFactor: (size) => Math.pow(size, 0.8) * 3,
fontFamily: 'Inter, "Microsoft YaHei", sans-serif',
color: 'random-dark',
backgroundColor: 'transparent',
rotateRatio: 0.5
});
};
const updateVisuals = () => {
// Use nextTick equivalent or small timeout to ensure DOM is ready
setTimeout(() => {
updateChart();
updateWordCloud();
}, 100);
};
const exportReport = () => {
const element = document.getElementById('report-area');
html2canvas(element, {
scale: 2,
backgroundColor: '#ffffff'
}).then(canvas => {
const link = document.createElement('a');
link.download = `PMF-Report-${new Date().toISOString().slice(0,10)}.png`;
link.href = canvas.toDataURL();
link.click();
});
};
onMounted(() => {
updateVisuals();
});
return {
entries,
newEntry,
bulkJson,
roles,
addEntry,
importJson,
resetData,
reversedEntries,
pmfScore,
totalResponses,
veryDisappointedCount,
roleDistribution,
currentDate,
getDisappointmentLabel,
getBadgeClass,
exportReport,
wordCloudEmpty,
handleFileUpload
};
}
}).mount('#app');
</script>
</body>
</html>