Files
opc-manager/backend/flask_app.py
mac 8dc69f8bd6 feat: OPC 工作台 — 科普(慰心斋)单项目管理系统
Flask + Tailwind CSS + Trix + Chart.js + Lucide Icons + SQLite

- 首页概览:关键指标卡片、财务趋势图、风险提醒、近期动态
- 销售管理:客户表格 + 抽屉详情(自动保存 + 评论)
- 业务方案:版本表格 + 抽屉(文件上传/预览/删除 + 评论)
- 运营管理:项目表格(业务机会/执行项目分类)+ 抽屉
- 产品研发:版本表格 + 抽屉
- 财务管理:月度收入/毛利/成本/净利曲线图 + 明细表
- 所有抽屉:Plane 风格紧凑布局、字段失焦自动保存、Trix 富文本评论框、点击遮罩关闭
2026-05-30 00:08:28 +08:00

480 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import date, datetime
from pathlib import Path
import os
import shutil
import sqlite3
from flask import Flask, jsonify, render_template, request, send_file
ROOT = Path(__file__).resolve().parents[1]
DATA_DIR = ROOT / "data"
UPLOAD_DIR = DATA_DIR / "uploads"
DB_PATH = DATA_DIR / "opc.sqlite"
WEIXIN_BASE = Path("/Users/mac/天机阁/地阁/慰心斋")
DATA_DIR.mkdir(parents=True, exist_ok=True)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
app = Flask(
__name__,
template_folder=str(ROOT / "templates"),
static_folder=str(ROOT / "static"),
)
def db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def now():
return datetime.utcnow().isoformat()
def rows(conn, sql, args=()):
return [dict(row) for row in conn.execute(sql, args).fetchall()]
def one(conn, sql, args=()):
row = conn.execute(sql, args).fetchone()
return dict(row) if row else None
def init_db():
conn = db()
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS sales_leads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_customer TEXT NOT NULL,
priority TEXT NOT NULL DEFAULT 'P1',
status TEXT NOT NULL DEFAULT '待跟进',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS follow_up_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_type TEXT NOT NULL,
target_id INTEGER NOT NULL,
followed_at TEXT NOT NULL DEFAULT '',
follower TEXT NOT NULL DEFAULT '慰心',
follow_up_method TEXT NOT NULL DEFAULT '记录',
content TEXT NOT NULL DEFAULT '',
next_action TEXT NOT NULL DEFAULT '',
next_follow_up_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS business_proposals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_or_project_name TEXT NOT NULL,
version TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '草稿',
created_date TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS operation_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_name TEXT NOT NULL,
project_version TEXT NOT NULL DEFAULT 'v1.0',
project_type TEXT NOT NULL DEFAULT 'opportunity',
project_status TEXT NOT NULL DEFAULT '',
current_stage TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL DEFAULT '慰心',
start_date TEXT NOT NULL DEFAULT '',
end_date TEXT NOT NULL DEFAULT '',
target_customer TEXT NOT NULL DEFAULT '',
customer_need TEXT NOT NULL DEFAULT '',
expected_contract_amount REAL NOT NULL DEFAULT 0,
expected_sign_date TEXT NOT NULL DEFAULT '',
sign_probability REAL NOT NULL DEFAULT 0,
next_action TEXT NOT NULL DEFAULT '',
related_business_proposal_id INTEGER,
sop_file_id INTEGER,
sop_stage TEXT NOT NULL DEFAULT '',
execution_progress REAL NOT NULL DEFAULT 0,
current_deliverable TEXT NOT NULL DEFAULT '',
risks TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS product_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_name TEXT NOT NULL,
version TEXT NOT NULL,
version_goal TEXT NOT NULL DEFAULT '',
feature_list TEXT NOT NULL DEFAULT '',
launch_date TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '规划中',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS finance_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
month TEXT NOT NULL,
project_name TEXT NOT NULL DEFAULT '科普(慰心斋)',
record_type TEXT NOT NULL,
category TEXT NOT NULL DEFAULT '',
amount REAL NOT NULL DEFAULT 0,
occurred_date TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS file_assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module TEXT NOT NULL,
owner_id INTEGER NOT NULL,
owner_version TEXT NOT NULL DEFAULT '',
file_category TEXT NOT NULL DEFAULT '',
file_name TEXT NOT NULL,
file_type TEXT NOT NULL DEFAULT '',
file_size INTEGER NOT NULL DEFAULT 0,
file_path TEXT NOT NULL,
is_external INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
"""
)
if one(conn, "SELECT id FROM sales_leads LIMIT 1"):
conn.close()
return
sales = [
("齐鲁制药", "P0", "跟进中", "多产品线科普年度框架,需推进高层沟通。"),
("百利天恒", "P0", "方案中", "BL-B01D1 上市前医生教育机会,准备方案。"),
("信达生物", "P0", "已签约", "现有科普项目升级/续约,重点保障执行。"),
("三生制药", "P1", "待跟进", "多科室医生教育+患者科普机会。"),
("天广实生物", "P1", "待跟进", "血液肿瘤医生教育机会。"),
]
for customer, priority, status, note in sales:
cur = conn.execute(
"INSERT INTO sales_leads (target_customer, priority, status) VALUES (?,?,?)",
(customer, priority, status),
)
conn.execute(
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("sales", cur.lastrowid, date.today().isoformat(), note, "明确下一次沟通人和时间"),
)
cur = conn.execute(
"INSERT INTO business_proposals (customer_or_project_name,version,description,status,created_date) VALUES (?,?,?,?,?)",
("信达生物", "v1.5", "信达科普项目续约与报价方案", "已提交客户", "2026-05-28"),
)
proposal_id = cur.lastrowid
proposal_dir = WEIXIN_BASE / "2、业务方案/信达/v1.5"
for category, names in {
"方案": ["整体方案.pptx", "整体方案.pdf"],
"成本": ["业务报价-2亿方案.xlsx", "业务报价-5250万方案.xlsx", "5、最新报价.xlsx"],
"SOP": ["SOP.docx"],
"财务流程": ["财务流程.docx"],
}.items():
for name in names:
add_file_index(conn, "proposal", proposal_id, "v1.5", category, proposal_dir / name, external=True)
projects = [
("圆心科技 科普文章项目", "v2026-文章", "execution", "SOP 执行中", "内容生产", 55, "文章内容生产与审核执行中"),
("圆心科技 科普视频项目", "v2026-视频", "execution", "SOP 执行中", "内容生产", 45, "视频脚本、拍摄与审核推进"),
("圆心科技 科普专访项目", "v2026-专访", "opportunity", "方案已提交", "商务推进", 0, "专访项目推动签约"),
]
op_dir = WEIXIN_BASE / "3、运营方案"
for name, version, kind, status, stage, progress, note in projects:
cur = conn.execute(
"""INSERT INTO operation_projects
(project_name,project_version,project_type,project_status,current_stage,target_customer,customer_need,
expected_contract_amount,expected_sign_date,sign_probability,next_action,sop_stage,execution_progress,current_deliverable)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(name, version, kind, status, stage, "圆心科技", "科普内容项目执行与管理", 0 if kind == "execution" else 200, "2026-06", 100 if kind == "execution" else 70, "补齐版本要求文件并更新下一节点", stage, progress, note),
)
conn.execute(
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("operation", cur.lastrowid, date.today().isoformat(), note, "补齐版本要求文件并更新下一节点"),
)
file_map = [
(1, "v2026-文章", "项目方案", "圆心科技--科普文章项目(1).pptx"),
(2, "v2026-视频", "项目方案", "圆心科技-科普视频项目(1).pptx"),
(3, "v2026-专访", "项目方案", "圆心科技-科普专访项目-2026年(1).pdf"),
(1, "v2026-文章", "项目管理手册", "圆心科技《项目管理手册》-2026年.pdf"),
(2, "v2026-视频", "审核标准", "科普项目-审核标准(文章-视频-音频).pdf"),
]
for project_id, version, category, filename in file_map:
add_file_index(conn, "operation", project_id, version, category, op_dir / filename, external=True)
products = [
("妙手医生服务小程序", "v1.1", "视频任务增强 + 积分商城", "草稿箱、批量上传、积分商城、消息通知", "2026-Q3", "规划中"),
("数字化营销后台管理系统", "v1.2", "运营数据看板 + 智能审核", "医生活跃、任务完成率、AI 预审、渠道数据上报", "2026-Q3", "设计中"),
("妙手患者服务", "v0.5", "科普浏览 + 医生主页 MVP", "科普文章/视频浏览、医生主页、搜索", "2026-Q3", "规划中"),
("数字人内容平台", "v0.1", "基础数字人视频生成 MVP", "预设形象、AI 配音、脚本驱动、简单模板", "2026-Q3", "规划中"),
("渠道分发引擎", "v1.0", "六渠道统一分发", "分发 API、内容适配、分发排期、效果追踪", "2027-Q1", "规划中"),
]
for product in products:
cur = conn.execute(
"INSERT INTO product_versions (product_name,version,version_goal,feature_list,launch_date,status) VALUES (?,?,?,?,?,?)",
product,
)
conn.execute(
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("product", cur.lastrowid, date.today().isoformat(), f"{product[0]} {product[1]}{product[2]}", "按路线图推进"),
)
for month, record_type, category, amount, notes in [
("2026-05", "revenue", "信达生物续约确认收入", 120, "信达项目阶段确收"),
("2026-06", "revenue", "信达生物续约确认收入", 80, "信达项目尾款预估"),
("2026-05", "cost_expense", "内容生产", 32, "医生劳务与内容制作"),
("2026-05", "cost_expense", "运营管理", 16, "项目管理与渠道协同"),
("2026-06", "cost_expense", "渠道分发", 24, "投放与分发费用"),
]:
conn.execute(
"INSERT INTO finance_records (month,record_type,category,amount,occurred_date,notes) VALUES (?,?,?,?,?,?)",
(month, record_type, category, amount, f"{month}-01", notes),
)
conn.commit()
conn.close()
def add_file_index(conn, module, owner_id, owner_version, category, path, external=True):
path = Path(path)
if not path.exists():
return
conn.execute(
"""INSERT INTO file_assets
(module,owner_id,owner_version,file_category,file_name,file_type,file_size,file_path,is_external)
VALUES (?,?,?,?,?,?,?,?,?)""",
(module, owner_id, owner_version, category, path.name, path.suffix.lower().lstrip("."), path.stat().st_size, str(path), 1 if external else 0),
)
def latest_followup(conn, target_type, target_id):
row = one(
conn,
"SELECT content FROM follow_up_records WHERE target_type=? AND target_id=? ORDER BY followed_at DESC, id DESC LIMIT 1",
(target_type, target_id),
)
return row["content"] if row else ""
def attach_common(conn, resource, items):
target_map = {"sales": "sales", "proposals": "proposal", "operations": "operation", "products": "product"}
for item in items:
if resource in target_map:
item["followups"] = rows(
conn,
"SELECT * FROM follow_up_records WHERE target_type=? AND target_id=? ORDER BY followed_at DESC, id DESC",
(target_map[resource], item["id"]),
)
item["latest_follow_up_record"] = item["followups"][0]["content"] if item["followups"] else ""
if resource == "proposals":
item["files"] = rows(conn, "SELECT * FROM file_assets WHERE module='proposal' AND owner_id=? ORDER BY id DESC", (item["id"],))
if resource == "operations":
item["files"] = rows(conn, "SELECT * FROM file_assets WHERE module='operation' AND owner_id=? ORDER BY id DESC", (item["id"],))
return items
def monthly_finance(conn):
data = []
for item in rows(conn, "SELECT DISTINCT month FROM finance_records ORDER BY month"):
month = item["month"]
revenue = one(conn, "SELECT COALESCE(SUM(amount),0) AS v FROM finance_records WHERE month=? AND record_type='revenue'", (month,))["v"]
cost = one(conn, "SELECT COALESCE(SUM(amount),0) AS v FROM finance_records WHERE month=? AND record_type='cost_expense'", (month,))["v"]
data.append({"month": month, "revenue": revenue, "gross_profit": revenue - cost, "cost_expense": cost, "net_profit": revenue - cost})
return data
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/bootstrap")
def bootstrap():
conn = db()
try:
sales = attach_common(conn, "sales", rows(conn, "SELECT * FROM sales_leads ORDER BY id DESC"))
proposals = attach_common(conn, "proposals", rows(conn, "SELECT * FROM business_proposals ORDER BY id DESC"))
operations = attach_common(conn, "operations", rows(conn, "SELECT * FROM operation_projects ORDER BY id DESC"))
products = attach_common(conn, "products", rows(conn, "SELECT * FROM product_versions ORDER BY id DESC"))
finance = rows(conn, "SELECT * FROM finance_records ORDER BY month DESC, id DESC")
current_month = "2026-05"
revenue = sum(x["amount"] for x in finance if x["month"] == current_month and x["record_type"] == "revenue")
cost = sum(x["amount"] for x in finance if x["month"] == current_month and x["record_type"] == "cost_expense")
summary = {
"project_name": "科普(慰心斋)",
"metrics": {
"p0_customers": len([x for x in sales if x["priority"] == "P0"]),
"active_sales": len([x for x in sales if x["status"] in ["待跟进", "跟进中", "方案中", "商务谈判"]]),
"execution_projects": len([x for x in operations if x["project_type"] == "execution"]),
"risk_projects": len([x for x in operations if x["project_status"] == "有风险" or x["risks"]]),
"monthly_revenue": revenue,
"monthly_net_profit": revenue - cost,
"upcoming_products": len([x for x in products if x["status"] in ["规划中", "设计中", "开发中", "测试中"]]),
},
"recent": rows(conn, "SELECT * FROM follow_up_records ORDER BY id DESC LIMIT 8"),
"risks": [{"title": "执行提醒", "content": x["next_action"]} for x in operations if x["next_action"]][:5],
}
return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "financeMonthly": monthly_finance(conn)})
finally:
conn.close()
TABLES = {
"sales": ("sales_leads", ["target_customer", "priority", "status"]),
"proposals": ("business_proposals", ["customer_or_project_name", "version", "description", "status", "created_date"]),
"operations": ("operation_projects", ["project_name", "project_version", "project_type", "project_status", "current_stage", "owner", "target_customer", "customer_need", "expected_contract_amount", "expected_sign_date", "sign_probability", "next_action", "sop_stage", "execution_progress", "current_deliverable", "risks", "notes"]),
"products": ("product_versions", ["product_name", "version", "version_goal", "feature_list", "launch_date", "status", "notes"]),
"finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes"]),
}
@app.route("/api/<resource>", methods=["POST"])
def create_resource(resource):
if resource not in TABLES:
return jsonify({"error": "unknown resource"}), 404
table, cols = TABLES[resource]
payload = request.get_json(force=True).get("data", {})
values = [payload.get(col, "") for col in cols]
conn = db()
try:
cur = conn.execute(f"INSERT INTO {table} ({','.join(cols)}) VALUES ({','.join(['?'] * len(cols))})", values)
conn.commit()
return jsonify({"id": cur.lastrowid})
finally:
conn.close()
@app.route("/api/<resource>/<int:item_id>", methods=["PUT", "DELETE"])
def update_resource(resource, item_id):
if resource not in TABLES:
return jsonify({"error": "unknown resource"}), 404
table, cols = TABLES[resource]
conn = db()
try:
if request.method == "DELETE":
conn.execute(f"DELETE FROM {table} WHERE id=?", (item_id,))
conn.commit()
return jsonify({"ok": True})
payload = request.get_json(force=True).get("data", {})
update_cols = [col for col in cols if col in payload]
if update_cols:
conn.execute(
f"UPDATE {table} SET {','.join([col + '=?' for col in update_cols])}, updated_at=? WHERE id=?",
[payload[col] for col in update_cols] + [now(), item_id],
)
conn.commit()
return jsonify({"ok": True})
finally:
conn.close()
@app.route("/api/followups/<target_type>/<int:target_id>", methods=["POST"])
def add_followup(target_type, target_id):
payload = request.get_json(force=True).get("data", {})
conn = db()
try:
conn.execute(
"""INSERT INTO follow_up_records
(target_type,target_id,followed_at,follower,follow_up_method,content,next_action,next_follow_up_at)
VALUES (?,?,?,?,?,?,?,?)""",
(
target_type,
target_id,
payload.get("followed_at") or date.today().isoformat(),
payload.get("follower") or "慰心",
payload.get("follow_up_method") or "记录",
payload.get("content") or "",
payload.get("next_action") or "",
payload.get("next_follow_up_at") or "",
),
)
conn.commit()
return jsonify({"ok": True})
finally:
conn.close()
@app.route("/api/followups/<int:followup_id>", methods=["DELETE"])
def delete_followup(followup_id):
conn = db()
try:
cur = conn.execute("DELETE FROM follow_up_records WHERE id=?", (followup_id,))
conn.commit()
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True})
finally:
conn.close()
@app.route("/api/files/upload", methods=["POST"])
def upload_file():
file = request.files["file"]
module = request.form["module"]
owner_id = int(request.form["owner_id"])
owner_version = request.form.get("owner_version", "")
category = request.form.get("file_category", "")
folder = UPLOAD_DIR / module / str(owner_id)
folder.mkdir(parents=True, exist_ok=True)
target = folder / file.filename
file.save(target)
conn = db()
try:
add_file_index(conn, module, owner_id, owner_version, category, target, external=False)
conn.commit()
return jsonify({"ok": True})
finally:
conn.close()
@app.route("/api/files/<int:file_id>/content")
def file_content(file_id):
conn = db()
try:
asset = one(conn, "SELECT * FROM file_assets WHERE id=?", (file_id,))
if not asset:
return jsonify({"error": "not found"}), 404
path = Path(asset["file_path"])
if not path.exists():
return jsonify({"error": "missing"}), 404
return send_file(path, as_attachment=request.args.get("inline") == "false", download_name=asset["file_name"])
finally:
conn.close()
@app.route("/api/files/<int:file_id>", methods=["DELETE"])
def delete_file(file_id):
conn = db()
try:
asset = one(conn, "SELECT * FROM file_assets WHERE id=?", (file_id,))
if not asset:
return jsonify({"error": "not found"}), 404
# Remove physical file from uploads/ if it was uploaded to our dir
path = Path(asset["file_path"])
if path.exists() and str(UPLOAD_DIR) in str(path.resolve()):
path.unlink(missing_ok=True)
conn.execute("DELETE FROM file_assets WHERE id=?", (file_id,))
conn.commit()
return jsonify({"ok": True})
finally:
conn.close()
@app.route("/api/health")
def health():
return jsonify({"ok": True, "db": str(DB_PATH)})
init_db()
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5177, debug=True)