Trae Assistant commited on
Commit
75f31eb
·
0 Parent(s):

Initial commit with optimized app and Dockerfile

Browse files
Files changed (4) hide show
  1. .gitignore +8 -0
  2. Dockerfile +30 -0
  3. README.md +49 -0
  4. app.py +573 -0
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ .DS_Store
5
+ .env
6
+ .venv
7
+ env/
8
+ venv/
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ # 设置环境变量,优化 Python 运行
4
+ ENV PYTHONDONTWRITEBYTECODE=1 \
5
+ PYTHONUNBUFFERED=1
6
+
7
+ WORKDIR /app
8
+
9
+ # 创建非 root 用户
10
+ RUN useradd -m -u 1000 user
11
+
12
+ # 安装系统依赖(如果是纯计算类应用,这一步可能不需要太多,但保持基本的更新是个好习惯)
13
+ RUN apt-get update && apt-get install -y --no-install-recommends \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ # 复制依赖文件(如果有 requirements.txt,虽然这里没有,直接安装)
17
+ # 建议显式列出依赖
18
+ RUN pip install --no-cache-dir flask gunicorn
19
+
20
+ # 复制应用代码
21
+ COPY --chown=user:user . .
22
+
23
+ # 切换到非 root 用户
24
+ USER user
25
+
26
+ # 暴露端口
27
+ EXPOSE 7860
28
+
29
+ # 启动命令
30
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: 市场规模估算大师
3
+ emoji: 📊
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ short_description: 创业融资辅助工具:TAM/SAM/SOM 计算与可视化
10
+ ---
11
+
12
+ # 市场规模估算大师 (Market Sizing Master)
13
+
14
+ ## 项目简介
15
+ 这是一个专为创业者和产品经理设计的**市场规模估算工具**。它可以帮助你快速计算并可视化 TAM (潜在总市场)、SAM (可服务市场) 和 SOM (可获得市场),生成专业的图表和融资路演话术。
16
+
17
+ ## 功能特点
18
+ - **双模式计算**:支持“自上而下 (Top-Down)”和“自下而上 (Bottom-Up)”两种估算逻辑。
19
+ - **动态可视化**:实时生成 TAM/SAM/SOM 同心圆气泡图,直观展示市场层级。
20
+ - **增长预测**:基于 CAGR (复合增长率) 自动生成未来5年的 SOM 增长柱状图。
21
+ - **路演话术生成**:根据计算结果,智能生成适合向投资人展示的 Pitch Script。
22
+ - **一键导出**:支持将分析结果导出为图片,直接用于 BP (商业计划书)。
23
+ - **本地存储**:所有数据自动保存在浏览器本地,保护商业机密。
24
+
25
+ ## 使用方法
26
+ 1. **基础设置**:输入项目名称、货币单位和起始年份。
27
+ 2. **选择模式**:
28
+ - **Top-Down**:输入行业总规模,设定 SAM 和 SOM 的占比。
29
+ - **Bottom-Up**:通过“客户数 x 客单价”反推 SOM,验证估算合理性。
30
+ 3. **查看结果**:右侧实时更新图表和核心指标。
31
+ 4. **导出报告**:点击右上角“导出报告”下载图片,或复制下方的路演话术。
32
+
33
+ ## 技术栈
34
+ - **Backend**: Flask (Python)
35
+ - **Frontend**: Vue 3 (Composition API)
36
+ - **Styling**: Tailwind CSS
37
+ - **Charts**: Chart.js
38
+ - **Export**: html2canvas
39
+
40
+ ## 部署
41
+ 本项目支持 Docker 部署,适配 Hugging Face Spaces。
42
+
43
+ ```bash
44
+ docker build -t market-sizing-master .
45
+ docker run -p 7860:7860 market-sizing-master
46
+ ```
47
+
48
+ ---
49
+ *Created by Trae AI Assistant*
app.py ADDED
@@ -0,0 +1,573 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template_string, jsonify, request
2
+ import os
3
+ import json
4
+
5
+ app = Flask(__name__)
6
+ app.config['SECRET_KEY'] = 'market-sizing-master-secret'
7
+
8
+ # 嵌入式 HTML 模板
9
+ HTML_TEMPLATE = """
10
+ <!DOCTYPE html>
11
+ <html lang="zh-CN">
12
+ <head>
13
+ <meta charset="UTF-8">
14
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
15
+ <title>市场规模估算大师 (Market Sizing Master)</title>
16
+ <!-- Tailwind CSS -->
17
+ <script src="https://cdn.tailwindcss.com"></script>
18
+ <!-- Vue 3 -->
19
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
20
+ <!-- Chart.js -->
21
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
22
+ <!-- html2canvas -->
23
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
24
+ <!-- Phosphor Icons -->
25
+ <script src="https://unpkg.com/@phosphor-icons/web"></script>
26
+ <style>
27
+ @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
28
+ body { font-family: 'Noto Sans SC', sans-serif; }
29
+ .gradient-bg { background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%); }
30
+ .card-shadow { box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); }
31
+ .input-group label { @apply block text-sm font-medium text-gray-700 mb-1; }
32
+ .input-group input, .input-group select { @apply w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2; }
33
+ </style>
34
+ </head>
35
+ <body class="bg-gray-50 min-h-screen">
36
+ <div id="app" class="pb-12">
37
+ <!-- Header -->
38
+ <header class="gradient-bg text-white shadow-lg">
39
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 flex justify-between items-center">
40
+ <div>
41
+ <h1 class="text-3xl font-bold flex items-center gap-2">
42
+ <i class="ph ph-chart-pie-slice"></i> 市场规模估算大师
43
+ </h1>
44
+ <p class="mt-2 text-blue-100 text-sm">专业的 TAM / SAM / SOM 计算与可视化工具,助力创业融资。</p>
45
+ </div>
46
+ <div class="flex gap-3">
47
+ <button @click="resetData" class="px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm transition flex items-center gap-2">
48
+ <i class="ph ph-arrow-counter-clockwise"></i> 重置
49
+ </button>
50
+ <button @click="exportImage" class="px-4 py-2 bg-white text-blue-800 hover:bg-blue-50 font-semibold rounded-lg text-sm shadow transition flex items-center gap-2">
51
+ <i class="ph ph-download-simple"></i> 导出报告
52
+ </button>
53
+ </div>
54
+ </div>
55
+ </header>
56
+
57
+ <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
58
+ <div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
59
+
60
+ <!-- Left Column: Inputs -->
61
+ <div class="lg:col-span-4 space-y-6">
62
+ <!-- Basic Info -->
63
+ <div class="bg-white rounded-xl card-shadow p-6">
64
+ <h2 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
65
+ <i class="ph ph-info"></i> 项目基础信息
66
+ </h2>
67
+ <div class="space-y-4">
68
+ <div class="input-group">
69
+ <label>项目名称</label>
70
+ <input v-model="project.name" type="text" placeholder="例如:AI 写作助手">
71
+ </div>
72
+ <div class="input-group">
73
+ <label>目标货币单位</label>
74
+ <select v-model="project.currency">
75
+ <option value="¥">人民币 (CNY)</option>
76
+ <option value="$">美元 (USD)</option>
77
+ <option value="€">欧元 (EUR)</option>
78
+ </select>
79
+ </div>
80
+ <div class="input-group">
81
+ <label>起始年份</label>
82
+ <input v-model.number="project.year" type="number" placeholder="2024">
83
+ </div>
84
+ </div>
85
+ </div>
86
+
87
+ <!-- Calculation Method Tabs -->
88
+ <div class="bg-white rounded-xl card-shadow overflow-hidden">
89
+ <div class="flex border-b">
90
+ <button
91
+ @click="mode = 'top-down'"
92
+ :class="{'bg-blue-50 text-blue-600 border-b-2 border-blue-600': mode === 'top-down', 'text-gray-500 hover:text-gray-700': mode !== 'top-down'}"
93
+ class="flex-1 py-3 text-sm font-medium transition"
94
+ >
95
+ 自上而下 (Top-Down)
96
+ </button>
97
+ <button
98
+ @click="mode = 'bottom-up'"
99
+ :class="{'bg-blue-50 text-blue-600 border-b-2 border-blue-600': mode === 'bottom-up', 'text-gray-500 hover:text-gray-700': mode !== 'bottom-up'}"
100
+ class="flex-1 py-3 text-sm font-medium transition"
101
+ >
102
+ 自下而上 (Bottom-Up)
103
+ </button>
104
+ </div>
105
+
106
+ <div class="p-6">
107
+ <!-- Top-Down Inputs -->
108
+ <div v-if="mode === 'top-down'" class="space-y-5">
109
+ <div>
110
+ <div class="flex justify-between items-center mb-1">
111
+ <label class="text-sm font-medium text-gray-700">TAM (潜在总市场)</label>
112
+ <span class="text-xs text-gray-500">整个市场的规模</span>
113
+ </div>
114
+ <div class="relative">
115
+ <span class="absolute left-3 top-2 text-gray-500">${ project.currency }</span>
116
+ <input v-model.number="inputs.tam_value" type="number" class="pl-8 w-full border rounded-md p-2" placeholder="0">
117
+ </div>
118
+ <p class="text-xs text-gray-400 mt-1">例如:全球 CRM 市场总额</p>
119
+ </div>
120
+
121
+ <div>
122
+ <div class="flex justify-between items-center mb-1">
123
+ <label class="text-sm font-medium text-gray-700">SAM (可服务市场)</label>
124
+ <span class="text-xs text-gray-500">你的业务能覆盖的部分</span>
125
+ </div>
126
+ <div class="flex gap-2">
127
+ <div class="relative w-2/3">
128
+ <span class="absolute left-3 top-2 text-gray-500">${ project.currency }</span>
129
+ <input :value="calculatedSAM" readonly type="text" class="pl-8 w-full border rounded-md p-2 bg-gray-50 text-gray-600">
130
+ </div>
131
+ <div class="relative w-1/3">
132
+ <input v-model.number="inputs.sam_percent" type="number" class="w-full border rounded-md p-2 pr-6" placeholder="20">
133
+ <span class="absolute right-2 top-2 text-gray-500">%</span>
134
+ </div>
135
+ </div>
136
+ <p class="text-xs text-gray-400 mt-1">TAM 的百分比,例如:针对中小企业的部分</p>
137
+ </div>
138
+
139
+ <div>
140
+ <div class="flex justify-between items-center mb-1">
141
+ <label class="text-sm font-medium text-gray-700">SOM (可获得市场)</label>
142
+ <span class="text-xs text-gray-500">短期内能拿下的部分</span>
143
+ </div>
144
+ <div class="flex gap-2">
145
+ <div class="relative w-2/3">
146
+ <span class="absolute left-3 top-2 text-gray-500">${ project.currency }</span>
147
+ <input :value="calculatedSOM" readonly type="text" class="pl-8 w-full border rounded-md p-2 bg-gray-50 text-gray-600">
148
+ </div>
149
+ <div class="relative w-1/3">
150
+ <input v-model.number="inputs.som_percent" type="number" class="w-full border rounded-md p-2 pr-6" placeholder="5">
151
+ <span class="absolute right-2 top-2 text-gray-500">%</span>
152
+ </div>
153
+ </div>
154
+ <p class="text-xs text-gray-400 mt-1">SAM 的百分比,例如:未来3年的目标份额</p>
155
+ </div>
156
+ </div>
157
+
158
+ <!-- Bottom-Up Inputs -->
159
+ <div v-else class="space-y-5">
160
+ <div class="input-group">
161
+ <label>客户总数 (Total Customers)</label>
162
+ <input v-model.number="inputs.bu_customers" type="number" placeholder="例如:10000">
163
+ </div>
164
+ <div class="input-group">
165
+ <label>年均客单价 (ACV)</label>
166
+ <div class="relative">
167
+ <span class="absolute left-3 top-2 text-gray-500">${ project.currency }</span>
168
+ <input v-model.number="inputs.bu_price" type="number" class="pl-8 w-full border rounded-md p-2" placeholder="0">
169
+ </div>
170
+ </div>
171
+ <div class="p-3 bg-blue-50 rounded-lg text-sm text-blue-800">
172
+ <div class="flex justify-between mb-1">
173
+ <span>SOM (年收入预估):</span>
174
+ <span class="font-bold">${ formatCurrency(bottomUpSOM) }</span>
175
+ </div>
176
+ <p class="text-xs opacity-75">基于客户数 x 客单价。请在 Top-Down 模式中手动调整 TAM/SAM 以匹配此逻辑(通常自下而上用于验证 SOM)。</p>
177
+ </div>
178
+ </div>
179
+
180
+ <hr class="my-4 border-gray-100">
181
+
182
+ <div class="input-group">
183
+ <label>市场年均复合增长率 (CAGR)</label>
184
+ <div class="relative">
185
+ <input v-model.number="inputs.cagr" type="number" class="w-full border rounded-md p-2 pr-8" placeholder="10">
186
+ <span class="absolute right-3 top-2 text-gray-500">%</span>
187
+ </div>
188
+ <p class="text-xs text-gray-400 mt-1">用于预测未来5年的市场规模</p>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ </div>
193
+
194
+ <!-- Right Column: Visualization & Report -->
195
+ <div class="lg:col-span-8 space-y-6">
196
+ <!-- Export Area Start -->
197
+ <div id="export-area" class="space-y-6 bg-gray-50 p-1"> <!-- Wrapper for screenshot -->
198
+
199
+ <!-- Visualizations -->
200
+ <div class="bg-white rounded-xl card-shadow p-6">
201
+ <div class="flex justify-between items-center mb-6">
202
+ <h2 class="text-xl font-bold text-gray-800">市场规模可视化</h2>
203
+ <div class="flex gap-2">
204
+ <span class="px-3 py-1 bg-blue-100 text-blue-800 text-xs rounded-full font-medium">TAM: ${ formatCurrency(finalTAM) }</span>
205
+ <span class="px-3 py-1 bg-green-100 text-green-800 text-xs rounded-full font-medium">SAM: ${ formatCurrency(finalSAM) }</span>
206
+ <span class="px-3 py-1 bg-purple-100 text-purple-800 text-xs rounded-full font-medium">SOM: ${ formatCurrency(finalSOM) }</span>
207
+ </div>
208
+ </div>
209
+
210
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-8">
211
+ <!-- Concentric Circles Chart -->
212
+ <div class="relative flex justify-center items-center h-64">
213
+ <canvas id="marketChart"></canvas>
214
+ </div>
215
+
216
+ <!-- Growth Projection Chart -->
217
+ <div class="h-64">
218
+ <canvas id="growthChart"></canvas>
219
+ </div>
220
+ </div>
221
+ </div>
222
+
223
+ <!-- Pitch Generator -->
224
+ <div class="bg-white rounded-xl card-shadow p-6">
225
+ <h2 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
226
+ <i class="ph ph-presentation-chart"></i> 投资者路演话术 (Pitch Script)
227
+ </h2>
228
+ <div class="bg-gray-50 border border-gray-200 rounded-lg p-5 relative group">
229
+ <button @click="copyPitch" class="absolute right-3 top-3 p-2 text-gray-400 hover:text-blue-600 bg-white rounded-md shadow-sm opacity-0 group-hover:opacity-100 transition">
230
+ <i class="ph ph-copy"></i>
231
+ </button>
232
+ <p class="text-gray-700 leading-relaxed whitespace-pre-line">${ pitchText }</p>
233
+ </div>
234
+ </div>
235
+
236
+ <!-- Summary Table -->
237
+ <div class="bg-white rounded-xl card-shadow overflow-hidden">
238
+ <table class="min-w-full divide-y divide-gray-200">
239
+ <thead class="bg-gray-50">
240
+ <tr>
241
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">指标</th>
242
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">定义</th>
243
+ <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">估算值</th>
244
+ </tr>
245
+ </thead>
246
+ <tbody class="bg-white divide-y divide-gray-200">
247
+ <tr>
248
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-blue-900">TAM</td>
249
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">潜在总市场 (Total Addressable Market)</td>
250
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-right font-bold text-gray-900">${ formatCurrency(finalTAM) }</td>
251
+ </tr>
252
+ <tr>
253
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-green-900">SAM</td>
254
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">可服务市场 (Serviceable Available Market)</td>
255
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-right font-bold text-gray-900">${ formatCurrency(finalSAM) }</td>
256
+ </tr>
257
+ <tr>
258
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-purple-900">SOM</td>
259
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">可获得市场 (Serviceable Obtainable Market)</td>
260
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-right font-bold text-gray-900">${ formatCurrency(finalSOM) }</td>
261
+ </tr>
262
+ </tbody>
263
+ </table>
264
+ </div>
265
+
266
+ </div> <!-- End Export Area -->
267
+ </div>
268
+ </div>
269
+ </main>
270
+
271
+ <!-- Toast Notification -->
272
+ <div v-if="toast.show" class="fixed bottom-4 right-4 bg-gray-800 text-white px-6 py-3 rounded-lg shadow-xl transform transition-all duration-300 z-50 flex items-center gap-2">
273
+ <i class="ph ph-check-circle text-green-400"></i>
274
+ ${ toast.message }
275
+ </div>
276
+ </div>
277
+
278
+ <script>
279
+ const { createApp, ref, computed, watch, onMounted, nextTick } = Vue;
280
+
281
+ createApp({
282
+ compilerOptions: {
283
+ delimiters: ['${', '}']
284
+ },
285
+ setup() {
286
+ // State
287
+ const project = ref({
288
+ name: '我的创业项目',
289
+ currency: '¥',
290
+ year: 2025
291
+ });
292
+
293
+ const mode = ref('top-down');
294
+
295
+ const inputs = ref({
296
+ tam_value: 10000000000,
297
+ sam_percent: 20,
298
+ som_percent: 5,
299
+ bu_customers: 10000,
300
+ bu_price: 5000,
301
+ cagr: 12.5
302
+ });
303
+
304
+ const toast = ref({ show: false, message: '' });
305
+
306
+ // Charts instances
307
+ let marketChart = null;
308
+ let growthChart = null;
309
+
310
+ // Computed
311
+ const calculatedSAM = computed(() => {
312
+ return inputs.value.tam_value * (inputs.value.sam_percent / 100);
313
+ });
314
+
315
+ const calculatedSOM = computed(() => {
316
+ return calculatedSAM.value * (inputs.value.som_percent / 100);
317
+ });
318
+
319
+ const bottomUpSOM = computed(() => {
320
+ return inputs.value.bu_customers * inputs.value.bu_price;
321
+ });
322
+
323
+ const finalTAM = computed(() => inputs.value.tam_value);
324
+
325
+ const finalSAM = computed(() => {
326
+ if (mode.value === 'top-down') return calculatedSAM.value;
327
+ // In bottom-up, SAM/TAM logic is inverse or manual, but for simplicity we keep Top-Down logic for SAM/TAM
328
+ // and just show Bottom-Up result as SOM comparison or override
329
+ return calculatedSAM.value;
330
+ });
331
+
332
+ const finalSOM = computed(() => {
333
+ if (mode.value === 'bottom-up') return bottomUpSOM.value;
334
+ return calculatedSOM.value;
335
+ });
336
+
337
+ const pitchText = computed(() => {
338
+ const c = project.value.currency;
339
+ const tam = formatNumber(finalTAM.value);
340
+ const sam = formatNumber(finalSAM.value);
341
+ const som = formatNumber(finalSOM.value);
342
+
343
+ return `我们在 ${project.value.year} 年面临一个 ${c}${tam} 的巨大潜在市场 (TAM)。\n\n` +
344
+ `其中,我们的产品主要服务于核心细分领域,可服务市场规模 (SAM) 达到 ${c}${sam}。\n\n` +
345
+ `基于我们目前的市场策略和增长计划,我们有信心在短期内拿下 ${c}${som} 的可获得市场份额 (SOM)。\n\n` +
346
+ `此外,该市场正以每年 ${inputs.value.cagr}% 的复合增长率高速扩张,为我们提供了极具吸引力的增长上限。`;
347
+ });
348
+
349
+ // Methods
350
+ const formatNumber = (num) => {
351
+ if (num >= 100000000) return (num / 100000000).toFixed(1) + '亿';
352
+ if (num >= 10000) return (num / 10000).toFixed(1) + '万';
353
+ return num.toLocaleString();
354
+ };
355
+
356
+ const formatCurrency = (val) => {
357
+ return project.value.currency + formatNumber(val);
358
+ };
359
+
360
+ const showToast = (msg) => {
361
+ toast.value = { show: true, message: msg };
362
+ setTimeout(() => toast.value.show = false, 3000);
363
+ };
364
+
365
+ const copyPitch = () => {
366
+ navigator.clipboard.writeText(pitchText.value);
367
+ showToast('话术已复制到剪贴板');
368
+ };
369
+
370
+ const resetData = () => {
371
+ if(confirm('确定要重置所有数据吗?')) {
372
+ inputs.value = {
373
+ tam_value: 10000000000,
374
+ sam_percent: 20,
375
+ som_percent: 5,
376
+ bu_customers: 10000,
377
+ bu_price: 5000,
378
+ cagr: 12.5
379
+ };
380
+ project.value.name = '我的创业项目';
381
+ showToast('数据已重置');
382
+ }
383
+ };
384
+
385
+ const exportImage = () => {
386
+ const element = document.getElementById('export-area');
387
+ showToast('正在生成图片,请稍候...');
388
+ html2canvas(element, {
389
+ backgroundColor: '#f9fafb',
390
+ scale: 2
391
+ }).then(canvas => {
392
+ const link = document.createElement('a');
393
+ link.download = `Market-Sizing-${project.value.name}.png`;
394
+ link.href = canvas.toDataURL();
395
+ link.click();
396
+ showToast('图片下载成功');
397
+ });
398
+ };
399
+
400
+ // Chart Logic
401
+ const updateCharts = () => {
402
+ if (!marketChart || !growthChart) return;
403
+
404
+ // Update Market Chart (Polar Area or Doughnut to simulate concentric?
405
+ // Actually Bubble chart is best for concentric, or just overlaying datasets in Radar/Polar.
406
+ // A simple way to visualize TAM/SAM/SOM is a bar chart or a custom Bubble chart where x=0, y=0, r=size.
407
+ // Let's use a Polar Area chart but hacked, or just a Bar chart.
408
+ // Classic deck uses circles inside circles.
409
+ // Let's use a Bubble Chart with 3 bubbles at same center, varying radius.
410
+
411
+ // Radius should be proportional to sqrt(Area/Value)
412
+ const rTAM = Math.sqrt(finalTAM.value);
413
+ const rSAM = Math.sqrt(finalSAM.value);
414
+ const rSOM = Math.sqrt(finalSOM.value);
415
+
416
+ // Normalize radius for display (max radius 50)
417
+ const maxR = rTAM;
418
+ const nTAM = 50;
419
+ const nSAM = (rSAM / maxR) * 50;
420
+ const nSOM = (rSOM / maxR) * 50;
421
+
422
+ marketChart.data.datasets = [
423
+ {
424
+ label: 'TAM',
425
+ data: [{x: 0, y: 0, r: nTAM}],
426
+ backgroundColor: 'rgba(59, 130, 246, 0.2)', // Blue
427
+ borderColor: 'rgba(59, 130, 246, 1)',
428
+ borderWidth: 2
429
+ },
430
+ {
431
+ label: 'SAM',
432
+ data: [{x: 0, y: 0, r: nSAM}],
433
+ backgroundColor: 'rgba(16, 185, 129, 0.3)', // Green
434
+ borderColor: 'rgba(16, 185, 129, 1)',
435
+ borderWidth: 2
436
+ },
437
+ {
438
+ label: 'SOM',
439
+ data: [{x: 0, y: 0, r: nSOM}],
440
+ backgroundColor: 'rgba(147, 51, 234, 0.5)', // Purple
441
+ borderColor: 'rgba(147, 51, 234, 1)',
442
+ borderWidth: 2
443
+ }
444
+ ];
445
+ marketChart.update();
446
+
447
+ // Update Growth Chart
448
+ const labels = [];
449
+ const data = [];
450
+ let current = finalSOM.value; // Projecting SOM growth usually
451
+ for(let i=0; i<5; i++) {
452
+ labels.push(`${project.value.year + i}`);
453
+ data.push(current);
454
+ current = current * (1 + inputs.value.cagr / 100);
455
+ }
456
+
457
+ growthChart.data.labels = labels;
458
+ growthChart.data.datasets[0].data = data;
459
+ growthChart.data.datasets[0].label = `SOM 增长预测 (CAGR ${inputs.value.cagr}%)`;
460
+ growthChart.update();
461
+ };
462
+
463
+ onMounted(() => {
464
+ // Initialize Market Chart (Bubble for Concentric Circles)
465
+ const ctx1 = document.getElementById('marketChart').getContext('2d');
466
+ marketChart = new Chart(ctx1, {
467
+ type: 'bubble',
468
+ data: { datasets: [] },
469
+ options: {
470
+ responsive: true,
471
+ maintainAspectRatio: false,
472
+ scales: {
473
+ x: { display: false, min: -60, max: 60 },
474
+ y: { display: false, min: -60, max: 60 }
475
+ },
476
+ plugins: {
477
+ tooltip: {
478
+ callbacks: {
479
+ label: function(context) {
480
+ const label = context.dataset.label;
481
+ const val = [finalTAM.value, finalSAM.value, finalSOM.value][context.datasetIndex];
482
+ return `${label}: ${formatCurrency(val)}`;
483
+ }
484
+ }
485
+ },
486
+ legend: { position: 'bottom' }
487
+ }
488
+ }
489
+ });
490
+
491
+ // Initialize Growth Chart
492
+ const ctx2 = document.getElementById('growthChart').getContext('2d');
493
+ growthChart = new Chart(ctx2, {
494
+ type: 'bar',
495
+ data: {
496
+ labels: [],
497
+ datasets: [{
498
+ label: 'Growth',
499
+ data: [],
500
+ backgroundColor: '#9333ea',
501
+ borderRadius: 6
502
+ }]
503
+ },
504
+ options: {
505
+ responsive: true,
506
+ maintainAspectRatio: false,
507
+ plugins: {
508
+ legend: { display: true, position: 'bottom' }
509
+ },
510
+ scales: {
511
+ y: { beginAtZero: true }
512
+ }
513
+ }
514
+ });
515
+
516
+ // Watch for changes
517
+ watch([inputs, mode, project], () => {
518
+ updateCharts();
519
+ // Save to local storage
520
+ localStorage.setItem('market-sizing-data', JSON.stringify({
521
+ inputs: inputs.value,
522
+ project: project.value,
523
+ mode: mode.value
524
+ }));
525
+ }, { deep: true });
526
+
527
+ // Load from local storage
528
+ const saved = localStorage.getItem('market-sizing-data');
529
+ if (saved) {
530
+ try {
531
+ const parsed = JSON.parse(saved);
532
+ if(parsed.inputs) inputs.value = {...inputs.value, ...parsed.inputs};
533
+ if(parsed.project) project.value = {...project.value, ...parsed.project};
534
+ if(parsed.mode) mode.value = parsed.mode;
535
+ } catch(e) { console.error(e); }
536
+ }
537
+
538
+ // Initial update
539
+ nextTick(updateCharts);
540
+ });
541
+
542
+ return {
543
+ project, mode, inputs, toast,
544
+ calculatedSAM, calculatedSOM, bottomUpSOM,
545
+ finalTAM, finalSAM, finalSOM,
546
+ pitchText,
547
+ formatNumber, formatCurrency,
548
+ copyPitch, resetData, exportImage
549
+ };
550
+ }
551
+ }).mount('#app');
552
+ </script>
553
+ </body>
554
+ </html>
555
+ """
556
+
557
+ @app.route('/')
558
+ def index():
559
+ # 使用 make_response 直接返回 HTML,避免 Jinja2 模板解析
560
+ # 这样可以防止 Vue.js 的定界符与 Jinja2 冲突,也避免了 TemplateSyntaxError
561
+ response = make_response(HTML_TEMPLATE)
562
+ response.headers['Content-Type'] = 'text/html; charset=utf-8'
563
+ return response
564
+
565
+ @app.route('/health')
566
+ def health():
567
+ return jsonify({"status": "healthy"}), 200
568
+
569
+ if __name__ == '__main__':
570
+ # 生产环境通常不开启 debug,但在 Spaces 中开启有助于调试
571
+ debug_mode = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'
572
+ port = int(os.environ.get('PORT', 7860))
573
+ app.run(host='0.0.0.0', port=port, debug=debug_mode)