Spaces:
Sleeping
Sleeping
Commit ·
c6ef3a7
0
Parent(s):
Initial commit: Lightweight CRM
Browse files- Dockerfile +26 -0
- README.md +72 -0
- app.py +115 -0
- clients.db +0 -0
- requirements.txt +2 -0
- 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">​</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>
|