duqing2026 commited on
Commit
c6ef3a7
·
0 Parent(s):

Initial commit: Lightweight CRM

Browse files
Files changed (6) hide show
  1. Dockerfile +26 -0
  2. README.md +72 -0
  3. app.py +115 -0
  4. clients.db +0 -0
  5. requirements.txt +2 -0
  6. templates/index.html +252 -0
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ # Set up a new user named "user" with user ID 1000
4
+ RUN useradd -m -u 1000 user
5
+
6
+ # Switch to the "user" user
7
+ USER user
8
+
9
+ # Set home to the user's home directory
10
+ ENV HOME=/home/user \
11
+ PATH=/home/user/.local/bin:$PATH
12
+
13
+ # Set the working directory to the user's home directory
14
+ WORKDIR $HOME/app
15
+
16
+ # Copy the current directory contents into the container at $HOME/app setting the owner to the user
17
+ COPY --chown=user . $HOME/app
18
+
19
+ # Install any needed packages specified in requirements.txt
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
+
22
+ # Expose the port that the application listens on
23
+ EXPOSE 7860
24
+
25
+ # Run the application
26
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Lightweight CRM
3
+ emoji: 💼
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # 轻量级客户管理系统 (Lightweight CRM)
11
+
12
+ 这是一个简单、高效的客户关系管理系统,专为自由职业者、个人创作者和小微团队设计。它可以帮助你轻松追踪客户线索、管理销售流程,并统计预期收入。
13
+
14
+ ## ✨ 功能特点
15
+
16
+ - **📊 实时仪表盘**:直观展示总客户数、潜在总价值、跟进中和已成交的订单。
17
+ - **📋 看板式管理**:通过“线索”、“跟进中”、“已成交”、“已流失”四个阶段管理客户生命周期。
18
+ - **📱 响应式设计**:基于 Tailwind CSS,完美适配桌面和移动端。
19
+ - **⚡ 极速体验**:基于 Flask + SQLite + Alpine.js,轻量级,无复杂依赖。
20
+ - **🔒 数据安全**:数据存储在本地 SQLite 数据库中(Docker 部署时为容器内存储)。
21
+
22
+ ## 🛠️ 技术栈
23
+
24
+ - **后端**: Python Flask
25
+ - **数据库**: SQLite
26
+ - **前端**: HTML5, Tailwind CSS (CDN), Alpine.js (CDN)
27
+ - **部署**: Docker
28
+
29
+ ## 🚀 快速开始
30
+
31
+ ### 本地运行
32
+
33
+ 1. 克隆代码:
34
+ ```bash
35
+ git clone https://github.com/yourusername/lightweight-crm.git
36
+ cd lightweight-crm
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
+ ### Docker 部署
52
+
53
+ ```bash
54
+ docker build -t lightweight-crm .
55
+ docker run -p 7860:7860 lightweight-crm
56
+ ```
57
+
58
+ ## ☁️ Hugging Face Spaces 部署
59
+
60
+ 本项目已配置为可以直接部署到 Hugging Face Spaces (Docker SDK)。
61
+
62
+ 1. 创建一个新的 Space,SDK 选择 **Docker**。
63
+ 2. 将代码推送到 Space 的仓库。
64
+ 3. 等待构建完成即可使用。
65
+
66
+ ## 📝 备注
67
+
68
+ - 默认端口为 `7860`。
69
+ - 数据库文件名为 `clients.db`。
70
+
71
+ ---
72
+ Made with ❤️ by duqing26
app.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sqlite3
3
+ import datetime
4
+ from flask import Flask, render_template, request, jsonify, send_file
5
+
6
+ app = Flask(__name__)
7
+ DB_FILE = "clients.db"
8
+
9
+ def init_db():
10
+ conn = sqlite3.connect(DB_FILE)
11
+ c = conn.cursor()
12
+ c.execute('''CREATE TABLE IF NOT EXISTS clients (
13
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14
+ name TEXT NOT NULL,
15
+ company TEXT,
16
+ email TEXT,
17
+ phone TEXT,
18
+ status TEXT DEFAULT 'lead',
19
+ value REAL DEFAULT 0,
20
+ notes TEXT,
21
+ created_at TEXT
22
+ )''')
23
+ conn.commit()
24
+ conn.close()
25
+
26
+ def get_db_connection():
27
+ conn = sqlite3.connect(DB_FILE)
28
+ conn.row_factory = sqlite3.Row
29
+ return conn
30
+
31
+ @app.route('/')
32
+ def index():
33
+ return render_template('index.html')
34
+
35
+ @app.route('/api/clients', methods=['GET'])
36
+ def get_clients():
37
+ conn = get_db_connection()
38
+ clients = conn.execute('SELECT * FROM clients ORDER BY created_at DESC').fetchall()
39
+ conn.close()
40
+ return jsonify([dict(ix) for ix in clients])
41
+
42
+ @app.route('/api/clients', methods=['POST'])
43
+ def add_client():
44
+ data = request.json
45
+ name = data.get('name')
46
+ if not name:
47
+ return jsonify({'error': 'Name is required'}), 400
48
+
49
+ conn = get_db_connection()
50
+ c = conn.cursor()
51
+ c.execute('''INSERT INTO clients (name, company, email, phone, status, value, notes, created_at)
52
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
53
+ (name, data.get('company', ''), data.get('email', ''), data.get('phone', ''),
54
+ data.get('status', 'lead'), data.get('value', 0), data.get('notes', ''),
55
+ datetime.datetime.now().isoformat()))
56
+ conn.commit()
57
+ new_id = c.lastrowid
58
+ conn.close()
59
+ return jsonify({'id': new_id, 'message': 'Client added successfully'}), 201
60
+
61
+ @app.route('/api/clients/<int:client_id>', methods=['PUT'])
62
+ def update_client(client_id):
63
+ data = request.json
64
+ conn = get_db_connection()
65
+ c = conn.cursor()
66
+
67
+ # Dynamic update
68
+ fields = []
69
+ values = []
70
+ for key in ['name', 'company', 'email', 'phone', 'status', 'value', 'notes']:
71
+ if key in data:
72
+ fields.append(f"{key} = ?")
73
+ values.append(data[key])
74
+
75
+ if not fields:
76
+ return jsonify({'message': 'No fields to update'}), 200
77
+
78
+ values.append(client_id)
79
+ query = f"UPDATE clients SET {', '.join(fields)} WHERE id = ?"
80
+ c.execute(query, values)
81
+ conn.commit()
82
+ conn.close()
83
+ return jsonify({'message': 'Client updated successfully'})
84
+
85
+ @app.route('/api/clients/<int:client_id>', methods=['DELETE'])
86
+ def delete_client(client_id):
87
+ conn = get_db_connection()
88
+ conn.execute('DELETE FROM clients WHERE id = ?', (client_id,))
89
+ conn.commit()
90
+ conn.close()
91
+ return jsonify({'message': 'Client deleted successfully'})
92
+
93
+ @app.route('/api/stats', methods=['GET'])
94
+ def get_stats():
95
+ conn = get_db_connection()
96
+ total_clients = conn.execute('SELECT COUNT(*) FROM clients').fetchone()[0]
97
+ total_value = conn.execute('SELECT SUM(value) FROM clients').fetchone()[0] or 0
98
+
99
+ # Group by status
100
+ status_counts = conn.execute('SELECT status, COUNT(*) FROM clients GROUP BY status').fetchall()
101
+ status_data = {row['status']: row[1] for row in status_counts}
102
+
103
+ conn.close()
104
+ return jsonify({
105
+ 'total_clients': total_clients,
106
+ 'total_value': total_value,
107
+ 'status_counts': status_data
108
+ })
109
+
110
+ if __name__ == '__main__':
111
+ init_db()
112
+ # Ensure database file has correct permissions if created by Docker root
113
+ if os.path.exists(DB_FILE):
114
+ os.chmod(DB_FILE, 0o666)
115
+ app.run(host='0.0.0.0', port=7860)
clients.db ADDED
Binary file (12.3 kB). View file
 
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ Flask==3.0.0
2
+ gunicorn==21.2.0
templates/index.html ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>轻量级客户管理系统 (Lightweight CRM)</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
9
+ <style>
10
+ [x-cloak] { display: none !important; }
11
+ </style>
12
+ </head>
13
+ <body class="bg-gray-100 min-h-screen text-gray-800" x-data="crmApp()">
14
+
15
+ <!-- Navbar -->
16
+ <nav class="bg-white shadow-md p-4">
17
+ <div class="container mx-auto flex justify-between items-center">
18
+ <h1 class="text-xl font-bold text-blue-600 flex items-center gap-2">
19
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>
20
+ 轻量级客户管理
21
+ </h1>
22
+ <button @click="openModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow transition">
23
+ + 新增客户
24
+ </button>
25
+ </div>
26
+ </nav>
27
+
28
+ <!-- Dashboard Stats -->
29
+ <div class="container mx-auto mt-6 px-4">
30
+ <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
31
+ <div class="bg-white p-4 rounded-lg shadow border-l-4 border-blue-500">
32
+ <div class="text-gray-500 text-sm">总客户数</div>
33
+ <div class="text-2xl font-bold" x-text="stats.total_clients || 0"></div>
34
+ </div>
35
+ <div class="bg-white p-4 rounded-lg shadow border-l-4 border-green-500">
36
+ <div class="text-gray-500 text-sm">潜在总价值 (元)</div>
37
+ <div class="text-2xl font-bold" x-text="(stats.total_value || 0).toLocaleString()"></div>
38
+ </div>
39
+ <div class="bg-white p-4 rounded-lg shadow border-l-4 border-yellow-500">
40
+ <div class="text-gray-500 text-sm">跟进中 (Negotiating)</div>
41
+ <div class="text-2xl font-bold" x-text="stats.status_counts?.negotiating || 0"></div>
42
+ </div>
43
+ <div class="bg-white p-4 rounded-lg shadow border-l-4 border-indigo-500">
44
+ <div class="text-gray-500 text-sm">已成交 (Won)</div>
45
+ <div class="text-2xl font-bold" x-text="stats.status_counts?.won || 0"></div>
46
+ </div>
47
+ </div>
48
+ </div>
49
+
50
+ <!-- Kanban Board -->
51
+ <div class="container mx-auto mt-8 px-4 pb-12 overflow-x-auto">
52
+ <div class="flex gap-4 min-w-[1000px]">
53
+ <!-- Column: Lead -->
54
+ <template x-for="status in statusList" :key="status.key">
55
+ <div class="flex-1 min-w-[250px] bg-gray-200 rounded-lg p-3">
56
+ <div class="flex justify-between items-center mb-3">
57
+ <h3 class="font-bold text-gray-700" x-text="status.label"></h3>
58
+ <span class="bg-gray-300 text-gray-600 text-xs px-2 py-1 rounded-full" x-text="clientsByStatus[status.key]?.length || 0"></span>
59
+ </div>
60
+ <div class="space-y-3">
61
+ <template x-for="client in clientsByStatus[status.key]" :key="client.id">
62
+ <div class="bg-white p-3 rounded shadow hover:shadow-md cursor-pointer transition border-t-2"
63
+ :class="status.colorClass"
64
+ @click="editClient(client)">
65
+ <div class="flex justify-between items-start">
66
+ <div class="font-bold text-gray-800" x-text="client.name"></div>
67
+ <div class="text-xs font-semibold text-green-600" x-show="client.value > 0">
68
+ ¥<span x-text="client.value.toLocaleString()"></span>
69
+ </div>
70
+ </div>
71
+ <div class="text-sm text-gray-600 mt-1 truncate" x-text="client.company || '无公司信息'"></div>
72
+ <div class="text-xs text-gray-400 mt-2 flex justify-between">
73
+ <span x-text="formatDate(client.created_at)"></span>
74
+ </div>
75
+ </div>
76
+ </template>
77
+ <div x-show="!clientsByStatus[status.key]?.length" class="text-center text-gray-400 text-sm py-4">
78
+ 暂无客户
79
+ </div>
80
+ </div>
81
+ </div>
82
+ </template>
83
+ </div>
84
+ </div>
85
+
86
+ <!-- Modal -->
87
+ <div x-show="isModalOpen" class="fixed inset-0 z-50 overflow-y-auto" x-cloak>
88
+ <div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
89
+ <div class="fixed inset-0 transition-opacity" aria-hidden="true" @click="closeModal()">
90
+ <div class="absolute inset-0 bg-gray-500 opacity-75"></div>
91
+ </div>
92
+
93
+ <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
94
+
95
+ <div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
96
+ <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
97
+ <div class="sm:flex sm:items-start">
98
+ <div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
99
+ <h3 class="text-lg leading-6 font-medium text-gray-900" x-text="isEditMode ? '编辑客户' : '新增客户'"></h3>
100
+ <div class="mt-4 space-y-4">
101
+ <div>
102
+ <label class="block text-sm font-medium text-gray-700">姓名 *</label>
103
+ <input type="text" x-model="form.name" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 focus:ring-blue-500 focus:border-blue-500">
104
+ </div>
105
+ <div>
106
+ <label class="block text-sm font-medium text-gray-700">公司</label>
107
+ <input type="text" x-model="form.company" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2">
108
+ </div>
109
+ <div class="grid grid-cols-2 gap-4">
110
+ <div>
111
+ <label class="block text-sm font-medium text-gray-700">电话</label>
112
+ <input type="text" x-model="form.phone" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2">
113
+ </div>
114
+ <div>
115
+ <label class="block text-sm font-medium text-gray-700">邮箱</label>
116
+ <input type="email" x-model="form.email" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2">
117
+ </div>
118
+ </div>
119
+ <div class="grid grid-cols-2 gap-4">
120
+ <div>
121
+ <label class="block text-sm font-medium text-gray-700">状态</label>
122
+ <select x-model="form.status" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 bg-white">
123
+ <template x-for="s in statusList" :key="s.key">
124
+ <option :value="s.key" x-text="s.label"></option>
125
+ </template>
126
+ </select>
127
+ </div>
128
+ <div>
129
+ <label class="block text-sm font-medium text-gray-700">预期价值 (元)</label>
130
+ <input type="number" x-model="form.value" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2">
131
+ </div>
132
+ </div>
133
+ <div>
134
+ <label class="block text-sm font-medium text-gray-700">备注</label>
135
+ <textarea x-model="form.notes" rows="3" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"></textarea>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ <div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse justify-between">
142
+ <div class="flex flex-row-reverse">
143
+ <button type="button" @click="saveClient()" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm">
144
+ 保存
145
+ </button>
146
+ <button type="button" @click="closeModal()" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
147
+ 取消
148
+ </button>
149
+ </div>
150
+ <button x-show="isEditMode" @click="deleteClient()" type="button" class="mt-3 w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none sm:mt-0 sm:w-auto sm:text-sm">
151
+ 删除
152
+ </button>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ </div>
157
+
158
+ <script>
159
+ function crmApp() {
160
+ return {
161
+ clients: [],
162
+ stats: {},
163
+ isModalOpen: false,
164
+ isEditMode: false,
165
+ form: {
166
+ id: null, name: '', company: '', phone: '', email: '', status: 'lead', value: 0, notes: ''
167
+ },
168
+ statusList: [
169
+ { key: 'lead', label: '线索 (Lead)', colorClass: 'border-blue-500' },
170
+ { key: 'negotiating', label: '跟进中 (Negotiating)', colorClass: 'border-yellow-500' },
171
+ { key: 'won', label: '已成交 (Won)', colorClass: 'border-green-500' },
172
+ { key: 'lost', label: '已流失 (Lost)', colorClass: 'border-gray-500' }
173
+ ],
174
+ get clientsByStatus() {
175
+ const grouped = {};
176
+ this.statusList.forEach(s => grouped[s.key] = []);
177
+ this.clients.forEach(c => {
178
+ if (grouped[c.status]) {
179
+ grouped[c.status].push(c);
180
+ }
181
+ });
182
+ return grouped;
183
+ },
184
+ init() {
185
+ this.fetchClients();
186
+ this.fetchStats();
187
+ },
188
+ async fetchClients() {
189
+ const res = await fetch('/api/clients');
190
+ this.clients = await res.json();
191
+ },
192
+ async fetchStats() {
193
+ const res = await fetch('/api/stats');
194
+ this.stats = await res.json();
195
+ },
196
+ openModal() {
197
+ this.isEditMode = false;
198
+ this.form = { id: null, name: '', company: '', phone: '', email: '', status: 'lead', value: 0, notes: '' };
199
+ this.isModalOpen = true;
200
+ },
201
+ editClient(client) {
202
+ this.isEditMode = true;
203
+ this.form = { ...client };
204
+ this.isModalOpen = true;
205
+ },
206
+ closeModal() {
207
+ this.isModalOpen = false;
208
+ },
209
+ async saveClient() {
210
+ if (!this.form.name) return alert('姓名不能为空');
211
+
212
+ const url = this.isEditMode ? `/api/clients/${this.form.id}` : '/api/clients';
213
+ const method = this.isEditMode ? 'PUT' : 'POST';
214
+
215
+ const res = await fetch(url, {
216
+ method: method,
217
+ headers: { 'Content-Type': 'application/json' },
218
+ body: JSON.stringify(this.form)
219
+ });
220
+
221
+ if (res.ok) {
222
+ this.closeModal();
223
+ this.fetchClients();
224
+ this.fetchStats();
225
+ } else {
226
+ alert('保存失败');
227
+ }
228
+ },
229
+ async deleteClient() {
230
+ if (!confirm('确定要删除这个客户吗?')) return;
231
+
232
+ const res = await fetch(`/api/clients/${this.form.id}`, {
233
+ method: 'DELETE'
234
+ });
235
+
236
+ if (res.ok) {
237
+ this.closeModal();
238
+ this.fetchClients();
239
+ this.fetchStats();
240
+ } else {
241
+ alert('删除失败');
242
+ }
243
+ },
244
+ formatDate(dateStr) {
245
+ if (!dateStr) return '';
246
+ return new Date(dateStr).toLocaleDateString('zh-CN');
247
+ }
248
+ }
249
+ }
250
+ </script>
251
+ </body>
252
+ </html>