Compare commits

..

60 Commits

Author SHA1 Message Date
mac
5b1dc4555f v1.0.1-beta: MySQL迁移 + 用户体系 + 经营管理/任务/产品改版 2026-06-22 19:34:31 +08:00
mac
353f11663c v3.3.0 — 左侧增加工作台切换侧边栏(科普/科研/医患) 2026-06-17 15:41:02 +08:00
mac
f3cf6902dd v3.2.1 — 去掉右上角刷新按钮 2026-06-17 15:39:27 +08:00
mac
25f3b9fe0d v3.2.0 — 财务项目增加总确收/总毛利列+弹窗计算显示 2026-06-17 15:36:41 +08:00
mac
cf08b2d241 v3.1.5 — 移除财务页面内嵌的旧表单,仅保留弹窗 2026-06-17 15:29:27 +08:00
mac
4911f24d40 v3.1.4 — 修复pfs变量丢失导致加载失败 2026-06-17 13:13:27 +08:00
mac
8c24abd53e v3.1.3 — 财务弹窗UI优化:分区卡片布局+月网格+圆角+更大尺寸 2026-06-17 13:12:39 +08:00
mac
0a7f70757d v3.1.2 — 财务弹窗按工作台显示项目和业务类型 2026-06-17 13:11:06 +08:00
mac
29dc7e040e v3.1.1 — 科普·无界新增"全品类科普"类型 2026-06-17 13:07:36 +08:00
mac
c8387011cc v3.1.0 — 财务分类重构:三大工作台13种业务类型+66条重新分配 2026-06-17 13:05:58 +08:00
mac
5061de70f8 v3.0.4 — 点击项目行弹出编辑弹窗+PUT保存 2026-06-17 11:27:29 +08:00
mac
ea3ba25da5 v3.0.3 — 修复:新增财务项目改为弹窗按钮+finFilter+createFinance修正 2026-06-17 11:17:12 +08:00
mac
bd7125fab8 v3.0.2 — 新增财务项目改为弹窗按钮+月度趋势缩减为6个月 2026-06-16 17:11:02 +08:00
mac
94dd1fe677 v3.0.1 — 财务项目明细已签/待签分Tab展示 2026-06-16 16:57:13 +08:00
mac
fa6c9b1711 v3.0 — 财务重构为项目财务视图:汇总卡片+项目明细+月度确收/毛利 2026-06-16 16:43:44 +08:00
mac
f4eacfafe2 v2.0.9 — 业务方案删除按钮改为通用deleteDrawerItem支持所有资源 2026-06-16 16:03:33 +08:00
mac
f8c816dc38 v2.0.8 — 任务行添加拖拽手柄图标(grip-vertical) 2026-06-16 16:01:54 +08:00
mac
e2d9049e45 v2.0.7 — 标题随项目切换动态变化:科普/科研/医患 OPC 工作台 2026-06-16 15:57:18 +08:00
mac
1b0049e342 v2.0.6 — X轴12月(前9+当前+后2) + Y轴万元 + 净利口径=确认收入-人力-费用-采购 2026-06-16 15:56:21 +08:00
mac
87a5d4f81d v2.0.5 — 财务曲线改为5类对应+月度净利=确认收入-人力成本-费用-外部采购 2026-06-16 15:54:24 +08:00
mac
d6ec7b24ec v2.0.4 — 曲线图默认折叠可展开,明细列表默认展示 2026-06-16 15:51:09 +08:00
mac
194c91cf25 v2.0.3 — 财务增加费用说明输入框 + 明细表默认折叠可展开 2026-06-16 15:49:35 +08:00
mac
68797e4fb5 v2.0.2 — 财务类型改为5类:签单/确认收入/人力成本/费用/外部采购 2026-06-16 15:47:44 +08:00
mac
af4ae1cbc3 v2.0.1 — 财务表单简化:日月合并+去类型+分类含签单+日历控件 2026-06-16 15:47:04 +08:00
mac
c42abb05da v2.0 — 多项目支持:右上角下拉切换科普/科研/医患三个项目 2026-06-16 15:42:28 +08:00
mac
4d1dc3b355 v1.8.1 — 新增项目表单默认展开 + 去掉按钮 + 按钮文字改为新增项目 2026-06-16 15:15:52 +08:00
mac
60bae583b2 v1.8.0 — 任务checkbox+删除线 + 拖拽排序 + 抽屉删除按钮 2026-06-16 15:14:31 +08:00
mac
c68fcaadcc v1.7.10 — 首页指标简化为6卡片:重点项目/业务方案/产品版本/本月确收/本月毛利/本月净利 2026-06-16 15:09:49 +08:00
mac
2c199aae76 v1.7.9 — 修复首页丢失 m 变量定义 2026-06-16 15:05:11 +08:00
mac
be6a7f5c38 v1.7.8 — 首页改为3表格卡片(合同/确收/毛利) + 合同时间维指标 2026-06-16 14:21:57 +08:00
mac
6adc00a8a7 v1.7.7 — 更新产品文档 PRD v2.0 + CHANGELOG v1.0→v2.0 2026-06-16 14:18:32 +08:00
mac
c4aacd5096 v1.7.6 — 项目抽屉增加红色删除按钮+确认对话框 2026-06-16 14:16:32 +08:00
mac
4f7db75a7f v1.7.5 — 任务页工具栏与卡片对齐 2026-06-16 11:26:17 +08:00
mac
9707009895 v1.7.4 — 项目抽屉「当前阶段」改为6阶段下拉 + select自动保存 2026-06-16 11:25:18 +08:00
mac
6defe95a3f v1.7.3 — 项目抽屉简化为6字段:名称/负责人/阶段/截止/金额/说明 2026-06-16 11:22:47 +08:00
mac
a1b154abd7 v1.7.2 — 返回项目列表后 Lucide 图标刷新 2026-06-16 11:20:10 +08:00
mac
fc0ee66064 v1.7.1 — 任务抽屉改为 fixed 定位,长列表不丢失 2026-06-16 11:18:04 +08:00
mac
5af58c8faa v1.7.0 — 任务清单改为 Tab 内二级页面,不再使用独立弹窗 2026-06-16 11:15:55 +08:00
mac
b682427ea0 v1.6.1 — 去除重点项目顶部阶段筛选分类标签 2026-06-16 11:13:30 +08:00
mac
ec83bb5e22 v1.6.0 — 任务编辑改为右侧抽屉滑入,顶栏不再内嵌表单 2026-06-16 11:11:34 +08:00
mac
921afd198b v1.5.1 — 修复编辑任务后误删DOM导致只剩一条 2026-06-16 10:57:29 +08:00
mac
850ad340e5 v1.5.0 — 项目阶段改为6阶段:商务洽谈/系统上线/团队分工/项目交付/上线推广/结项验收 2026-06-16 10:54:55 +08:00
mac
cec3e3a2ad v1.4.1 — 任务表单增加「卡点&备注」字段,任务行显示卡点红色标记 2026-06-16 10:33:58 +08:00
mac
e02c0bd183 v1.4.0 — 任务面板统一亮色 + 点击任务可编辑 + 任务说明自动换行 2026-06-16 10:32:34 +08:00
mac
853344fb26 v1.3.9 — 任务面板加宽到1000px + 横向表格布局 + 空分组自动隐藏 2026-06-16 10:28:48 +08:00
mac
386f780d00 v1.3.8 — 任务行始终渲染负责人和日期 + 加emoji图标 + 提亮颜色 2026-06-16 10:26:13 +08:00
mac
dd0c7d615f v1.3.7 — 加宽任务面板到800px + 显示任务说明+负责人+截止时间 2026-06-16 10:04:34 +08:00
mac
289529dc73 v1.3.6 — 合并负责人与截止时间到同一标签显示 2026-06-16 09:44:40 +08:00
mac
19dcbdbb51 v1.3.5 — 任务行增加负责人和截止时间显示 2026-06-16 09:42:07 +08:00
mac
b1a82d27e3 v1.3.4 — 修复新增任务递归调用导致创建失败 2026-06-16 09:40:08 +08:00
mac
ac5f9f2de9 v1.3.3 — 任务浮层新增统一「新增任务」按钮+表单弹窗 2026-06-16 09:14:38 +08:00
mac
a506b52506 v1.3.2 — 新增按钮移到右上角 + 收起表单 + 阶段筛选 2026-06-15 17:50:34 +08:00
mac
7ad363f39e v1.3.1 — 任务浮层 Plane 深色风格 + 项目去版本号 + 添加任务按钮 2026-06-15 17:33:34 +08:00
mac
938ec8d181 v1.3.0 — 重点项目简化:6列表格 + 进展按钮弹任务浮层(4阶段) 2026-06-15 12:02:27 +08:00
mac
55e7a3a4dc v1.2.1 — 修复合并时误删 renderProposals/fileGroup/fileItem 2026-06-15 11:13:46 +08:00
mac
301dfd0dfb v1.2.0 — 合并业务机会+运营为重点项目 Tab,新增项目任务时间线 2026-06-15 10:01:31 +08:00
mac
9930727d14 v1.1.0 — 首页指标升级 + 运营金额列 + 产品平台分类 2026-06-15 09:22:31 +08:00
mac
c5180cafbd v1.0.7 — 连修三 bug:表单事件丢失 + Chart 堆积 + 字段重命名 2026-06-04 14:33:21 +08:00
mac
256767ff57 v1.0.4 — CDN 全量本地化,不再依赖外部网络 2026-06-04 12:11:56 +08:00
mac
7009b7fe48 v1.0.3 — CDN 脚本加 defer + preconnect 解决页面加载阻塞 2026-06-04 12:07:26 +08:00
15 changed files with 9734 additions and 290 deletions

View File

@@ -1,5 +1,41 @@
# OPC Manager Version Log # OPC Manager Version Log
## v1.0.1-beta — 2026-06-22
- 数据库迁移SQLite → MySQL 9.6,适配占位符/类型/游标
- 用户体系:管理员 + OPC负责人角色工作台权限隔离登录鉴权
- 经营管理:状态改为已签约/流程中/待签约三类,签约月份列可编辑,财务指标卡片(确收/毛利/回款/费用/现金流)
- 重点工作与台账:阶段排序固定化+折叠分组,任务拖拽手柄与状态互换位置,关闭抽屉自动刷新,首次进入自动选第一个项目
- 产品迭代统一表格去平台tab状态点击循环切换日期改为 date 选择器,新增版本改用右侧抽屉
- 左侧工作台/顶部 tab 记忆恢复,跨工作台转移功能
- 近期动态修复 tenant 归属
## v1.2.0 — 2026-06-15
- 业务机会 + 运营管理合并为「重点项目」Tab统一表格展示
- 新增项目任务追踪:按阶段分组展示里程碑/执行项/负责人/截止日/卡点
- 新增 `project_tasks` 表,抽屉内展示项目时间线
## v1.1.0 — 2026-06-15
- 首页指标升级:增加已签约合同总额、合同流程中金额、年度/Q2 累计确收、年度/Q2 累计毛利、已签约未执行
- 运营表格增加「金额」列
- 产品研发增加「平台」字段(真研/科普/关爱),支持平台筛选
## v1.0.7 — 2026-06-04
- 修复新增表单 async 后 `event.currentTarget` 丢失导致页面不刷新(影响所有新增按钮)
- `createResource` 改用预存 form 引用 + try/catch 错误提示
## v1.0.6 — 2026-06-04
- 修复财务 Tab Chart 无限堆积renderChartOn 缺少旧 chart 销毁 + state 跟踪
- 财务图表容器加固定高度300px避免 resize 循环
## v1.0.5 — 2026-06-04
- "销售管理" Tab 改为"业务机会""目标客户"字段统一改为"业务机会"
## v1.0.4 — 2026-06-04
- CDN 全量本地化Tailwind / Chart.js / Squire / Lucide 下载到 `static/vendor/`,不再依赖外部 CDN
## v1.0.3 — 2026-06-04
- CDN 脚本加 `defer` + `preconnect`:解决 Chart.js / Squire / Lucide 同步加载阻塞页面渲染
## v1.0.2 — 2026-05-30 ## v1.0.2 — 2026-05-30
- 新增 Codex Skill`opc-manager`,说"打开 OPC 工作台"即可在任意上下文启动 - 新增 Codex Skill`opc-manager`,说"打开 OPC 工作台"即可在任意上下文启动

View File

@@ -2,9 +2,11 @@ from datetime import date, datetime
from pathlib import Path from pathlib import Path
import os import os
import shutil import shutil
import sqlite3 import sqlite3 # 保留用于数据迁移
import mysql.connector
from flask import Flask, jsonify, render_template, request, send_file from flask import Flask, jsonify, render_template, request, send_file, session, redirect
from werkzeug.security import generate_password_hash, check_password_hash
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -21,129 +23,324 @@ app = Flask(
template_folder=str(ROOT / "templates"), template_folder=str(ROOT / "templates"),
static_folder=str(ROOT / "static"), static_folder=str(ROOT / "static"),
) )
app.secret_key = os.environ.get("SECRET_KEY", "opc-dev-secret-2026")
# ---------- 鉴权 ----------
def login_required(f):
from functools import wraps
@wraps(f)
def decorated(*args, **kwargs):
if "user_id" not in session:
return jsonify({"error": "未登录"}), 401
return f(*args, **kwargs)
return decorated
@app.route("/login")
def login_page():
return render_template("login.html")
@app.route("/api/auth/login", methods=["POST"])
def auth_login():
data = request.get_json(force=True) or {}
username = data.get("username", "").strip()
password = data.get("password", "")
conn = db()
try:
user = one(conn, "SELECT * FROM users WHERE username=?", (username,))
if not user or not check_password_hash(user["password_hash"], password):
return jsonify({"error": "用户名或密码错误"}), 401
session["user_id"] = user["id"]
session["username"] = user["username"]
session["display_name"] = user["display_name"]
session["role"] = user["role"]
# 管理员可看所有工作台OPC负责人看分配的工作台
if user["role"] == "admin":
session["tenants"] = ["科普·无界", "科研·无界", "医患·无界"]
else:
ut = rows(conn, "SELECT tenant FROM user_tenants WHERE user_id=?", (user["id"],))
session["tenants"] = [x["tenant"] for x in ut]
return jsonify({
"ok": True,
"user": {"id": user["id"], "username": user["username"], "display_name": user["display_name"], "role": user["role"]},
"tenants": session["tenants"],
})
finally:
conn.close()
@app.route("/api/auth/logout", methods=["POST"])
def auth_logout():
session.clear()
return jsonify({"ok": True})
@app.route("/api/auth/me")
def auth_me():
if "user_id" not in session:
return jsonify({"logged_in": False})
return jsonify({
"logged_in": True,
"user": {"id": session["user_id"], "username": session["username"], "display_name": session["display_name"], "role": session["role"]},
"tenants": session.get("tenants", []),
})
# ---------- 业务 API ----------
def db(): def db():
conn = sqlite3.connect(DB_PATH) return mysql.connector.connect(
conn.row_factory = sqlite3.Row host="127.0.0.1",
return conn port=3306,
user="opc",
password="opc123456",
database="opc",
charset="utf8mb4",
collation="utf8mb4_unicode_ci",
)
def now(): def now():
return datetime.utcnow().isoformat() return datetime.utcnow().isoformat()
def _exec(conn, sql, args=()):
"""执行 SQL自动将 ? 转为 MySQL 的 %s"""
cur = conn.cursor(dictionary=True)
cur.execute(sql.replace("?", "%s"), args)
return cur
def rows(conn, sql, args=()): def rows(conn, sql, args=()):
return [dict(row) for row in conn.execute(sql, args).fetchall()] cur = _exec(conn, sql, args)
rows = cur.fetchall()
cur.close()
return rows
def one(conn, sql, args=()): def one(conn, sql, args=()):
row = conn.execute(sql, args).fetchone() cur = _exec(conn, sql, args)
return dict(row) if row else None row = cur.fetchone()
cur.close()
return row
def init_db(): def init_db():
conn = db() conn = db()
conn.executescript( _exec(conn, """CREATE TABLE IF NOT EXISTS sales_leads (
""" id INT AUTO_INCREMENT PRIMARY KEY,
CREATE TABLE IF NOT EXISTS sales_leads ( target_customer VARCHAR(1000) NOT NULL,
id INTEGER PRIMARY KEY AUTOINCREMENT, priority VARCHAR(1000) NOT NULL DEFAULT 'P1',
target_customer TEXT NOT NULL, status VARCHAR(1000) NOT NULL DEFAULT '待跟进',
priority TEXT NOT NULL DEFAULT 'P1', created_at VARCHAR(30) NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '待跟进', updated_at VARCHAR(30) NOT NULL DEFAULT ''
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, )""")
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP conn.commit()
);
CREATE TABLE IF NOT EXISTS follow_up_records ( _exec(conn, """CREATE TABLE IF NOT EXISTS follow_up_records (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INT AUTO_INCREMENT PRIMARY KEY,
target_type TEXT NOT NULL, target_type VARCHAR(1000) NOT NULL,
target_id INTEGER NOT NULL, target_id INT NOT NULL,
followed_at TEXT NOT NULL DEFAULT '', followed_at VARCHAR(1000) NOT NULL DEFAULT '',
follower TEXT NOT NULL DEFAULT '慰心', follower VARCHAR(1000) NOT NULL DEFAULT '慰心',
follow_up_method TEXT NOT NULL DEFAULT '记录', follow_up_method VARCHAR(1000) NOT NULL DEFAULT '记录',
content TEXT NOT NULL DEFAULT '', content VARCHAR(1000) NOT NULL DEFAULT '',
next_action TEXT NOT NULL DEFAULT '', next_action VARCHAR(1000) NOT NULL DEFAULT '',
next_follow_up_at TEXT NOT NULL DEFAULT '', next_follow_up_at VARCHAR(1000) NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at VARCHAR(30) NOT NULL DEFAULT ''
); )""")
CREATE TABLE IF NOT EXISTS business_proposals ( conn.commit()
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_or_project_name TEXT NOT NULL, _exec(conn, """CREATE TABLE IF NOT EXISTS business_proposals (
version TEXT NOT NULL, id INT AUTO_INCREMENT PRIMARY KEY,
description TEXT NOT NULL DEFAULT '', customer_or_project_name VARCHAR(1000) NOT NULL,
status TEXT NOT NULL DEFAULT '草稿', version VARCHAR(1000) NOT NULL,
created_date TEXT NOT NULL DEFAULT '', description VARCHAR(1000) NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, status VARCHAR(1000) NOT NULL DEFAULT '草稿',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_date VARCHAR(1000) NOT NULL DEFAULT '',
); created_at VARCHAR(30) NOT NULL DEFAULT '',
CREATE TABLE IF NOT EXISTS operation_projects ( updated_at VARCHAR(30) NOT NULL DEFAULT ''
id INTEGER PRIMARY KEY AUTOINCREMENT, )""")
project_name TEXT NOT NULL, conn.commit()
project_version TEXT NOT NULL DEFAULT 'v1.0',
project_type TEXT NOT NULL DEFAULT 'opportunity', _exec(conn, """CREATE TABLE IF NOT EXISTS operation_projects (
project_status TEXT NOT NULL DEFAULT '', id INT AUTO_INCREMENT PRIMARY KEY,
current_stage TEXT NOT NULL DEFAULT '', project_name VARCHAR(1000) NOT NULL,
owner TEXT NOT NULL DEFAULT '慰心', project_version VARCHAR(1000) NOT NULL DEFAULT 'v1.0',
start_date TEXT NOT NULL DEFAULT '', project_type VARCHAR(1000) NOT NULL DEFAULT 'opportunity',
end_date TEXT NOT NULL DEFAULT '', project_status VARCHAR(1000) NOT NULL DEFAULT '',
target_customer TEXT NOT NULL DEFAULT '', current_stage VARCHAR(1000) NOT NULL DEFAULT '',
customer_need TEXT NOT NULL DEFAULT '', owner VARCHAR(1000) NOT NULL DEFAULT '慰心',
expected_contract_amount REAL NOT NULL DEFAULT 0, start_date VARCHAR(1000) NOT NULL DEFAULT '',
expected_sign_date TEXT NOT NULL DEFAULT '', end_date VARCHAR(1000) NOT NULL DEFAULT '',
sign_probability REAL NOT NULL DEFAULT 0, target_customer VARCHAR(1000) NOT NULL DEFAULT '',
next_action TEXT NOT NULL DEFAULT '', customer_need VARCHAR(1000) NOT NULL DEFAULT '',
expected_contract_amount DOUBLE NOT NULL DEFAULT 0,
expected_sign_date VARCHAR(1000) NOT NULL DEFAULT '',
sign_probability DOUBLE NOT NULL DEFAULT 0,
next_action VARCHAR(1000) NOT NULL DEFAULT '',
related_business_proposal_id INTEGER, related_business_proposal_id INTEGER,
sop_file_id INTEGER, sop_file_id INTEGER,
sop_stage TEXT NOT NULL DEFAULT '', sop_stage VARCHAR(1000) NOT NULL DEFAULT '',
execution_progress REAL NOT NULL DEFAULT 0, execution_progress DOUBLE NOT NULL DEFAULT 0,
current_deliverable TEXT NOT NULL DEFAULT '', current_deliverable VARCHAR(1000) NOT NULL DEFAULT '',
risks TEXT NOT NULL DEFAULT '', risks VARCHAR(1000) NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '', notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at VARCHAR(30) NOT NULL DEFAULT ''
); )""")
CREATE TABLE IF NOT EXISTS product_versions ( conn.commit()
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_name TEXT NOT NULL, _exec(conn, """CREATE TABLE IF NOT EXISTS product_versions (
version TEXT NOT NULL, id INT AUTO_INCREMENT PRIMARY KEY,
version_goal TEXT NOT NULL DEFAULT '', product_name VARCHAR(1000) NOT NULL,
feature_list TEXT NOT NULL DEFAULT '', version VARCHAR(1000) NOT NULL,
launch_date TEXT NOT NULL DEFAULT '', version_goal VARCHAR(1000) NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '规划中', feature_list VARCHAR(1000) NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '', launch_date VARCHAR(1000) NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, status VARCHAR(1000) NOT NULL DEFAULT '规划中',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP notes VARCHAR(1000) NOT NULL DEFAULT '',
); created_at VARCHAR(30) NOT NULL DEFAULT '',
CREATE TABLE IF NOT EXISTS finance_records ( updated_at VARCHAR(30) NOT NULL DEFAULT ''
id INTEGER PRIMARY KEY AUTOINCREMENT, )""")
month TEXT NOT NULL, conn.commit()
project_name TEXT NOT NULL DEFAULT '科普(慰心斋)',
record_type TEXT NOT NULL, _exec(conn, """CREATE TABLE IF NOT EXISTS finance_records (
category TEXT NOT NULL DEFAULT '', id INT AUTO_INCREMENT PRIMARY KEY,
amount REAL NOT NULL DEFAULT 0, month VARCHAR(1000) NOT NULL,
occurred_date TEXT NOT NULL DEFAULT '', project_name VARCHAR(1000) NOT NULL DEFAULT '科普(慰心斋)',
notes TEXT NOT NULL DEFAULT '', record_type VARCHAR(1000) NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, category VARCHAR(1000) NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP amount DOUBLE NOT NULL DEFAULT 0,
); occurred_date VARCHAR(1000) NOT NULL DEFAULT '',
CREATE TABLE IF NOT EXISTS file_assets ( notes VARCHAR(1000) NOT NULL DEFAULT '',
id INTEGER PRIMARY KEY AUTOINCREMENT, created_at VARCHAR(30) NOT NULL DEFAULT '',
module TEXT NOT NULL, updated_at VARCHAR(30) NOT NULL DEFAULT ''
owner_id INTEGER NOT NULL, )""")
owner_version TEXT NOT NULL DEFAULT '', conn.commit()
file_category TEXT NOT NULL DEFAULT '',
file_name TEXT NOT NULL, _exec(conn, """CREATE TABLE IF NOT EXISTS file_assets (
file_type TEXT NOT NULL DEFAULT '', id INT AUTO_INCREMENT PRIMARY KEY,
module VARCHAR(1000) NOT NULL,
owner_id INT NOT NULL,
owner_version VARCHAR(1000) NOT NULL DEFAULT '',
file_category VARCHAR(1000) NOT NULL DEFAULT '',
file_name VARCHAR(1000) NOT NULL,
file_type VARCHAR(1000) NOT NULL DEFAULT '',
file_size INTEGER NOT NULL DEFAULT 0, file_size INTEGER NOT NULL DEFAULT 0,
file_path TEXT NOT NULL, file_path VARCHAR(1000) NOT NULL,
is_external INTEGER NOT NULL DEFAULT 0, is_external INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '', notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at VARCHAR(30) NOT NULL DEFAULT ''
); )""")
""" conn.commit()
)
_exec(conn, """CREATE TABLE IF NOT EXISTS project_tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INTEGER NOT NULL,
phase VARCHAR(1000) NOT NULL DEFAULT '',
milestone VARCHAR(1000) NOT NULL DEFAULT '',
task VARCHAR(1000) NOT NULL DEFAULT '',
owner VARCHAR(1000) NOT NULL DEFAULT '',
due_date VARCHAR(1000) NOT NULL DEFAULT '',
blockers VARCHAR(1000) NOT NULL DEFAULT '',
notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""")
conn.commit()
# 用户表
try: _exec(conn, """CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'opc_owner',
created_at VARCHAR(30) NOT NULL DEFAULT ''
)""")
except: pass
conn.commit()
# 用户-工作台关联表
try: _exec(conn, """CREATE TABLE IF NOT EXISTS user_tenants (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
tenant VARCHAR(100) NOT NULL,
UNIQUE KEY (user_id, tenant)
)""")
except: pass
conn.commit()
# project_finances 表(月度预算 + 签约信息)
try: _exec(conn, """CREATE TABLE IF NOT EXISTS project_finances (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界',
project_id VARCHAR(100) NOT NULL DEFAULT '',
business_type VARCHAR(100) NOT NULL DEFAULT '',
customer_name VARCHAR(200) NOT NULL DEFAULT '',
sign_amount DOUBLE NOT NULL DEFAULT 0,
sign_month VARCHAR(20) NOT NULL DEFAULT '',
status VARCHAR(50) NOT NULL DEFAULT '待签约',
sales_person VARCHAR(100) NOT NULL DEFAULT '',
total_rev DOUBLE NOT NULL DEFAULT 0,
total_gross DOUBLE NOT NULL DEFAULT 0,
budget_data TEXT,
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""")
except: pass
conn.commit()
# Schema migrations — 添加后续迁移的列(幂等)
migrations = [
"ALTER TABLE sales_leads ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE follow_up_records ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE business_proposals ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE business_proposals ADD COLUMN proposal_type VARCHAR(100) NOT NULL DEFAULT '业务方案'",
"ALTER TABLE business_proposals ADD COLUMN notes VARCHAR(2000) NOT NULL DEFAULT ''",
"ALTER TABLE operation_projects ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE product_versions ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE product_versions ADD COLUMN platform VARCHAR(100) NOT NULL DEFAULT ''",
"ALTER TABLE finance_records ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE project_tasks ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE project_tasks ADD COLUMN status VARCHAR(50) NOT NULL DEFAULT '未开始'",
"ALTER TABLE project_tasks ADD COLUMN sort_order INT NOT NULL DEFAULT 0",
"ALTER TABLE project_tasks ADD COLUMN priority VARCHAR(10) NOT NULL DEFAULT 'P2'",
# 12 月字段(确收/毛利/回款/费用/月度现金流)
]
for m in ["01","02","03","04","05","06","07","08","09","10","11","12"]:
migrations.append(f"ALTER TABLE project_finances ADD COLUMN rev_2026_{m} DOUBLE NOT NULL DEFAULT 0")
migrations.append(f"ALTER TABLE project_finances ADD COLUMN gross_2026_{m} DOUBLE NOT NULL DEFAULT 0")
migrations.append(f"ALTER TABLE project_finances ADD COLUMN payment_2026_{m} DOUBLE NOT NULL DEFAULT 0")
migrations.append(f"ALTER TABLE project_finances ADD COLUMN cost_2026_{m} DOUBLE NOT NULL DEFAULT 0")
for mig in migrations:
try: _exec(conn, mig)
except: pass
conn.commit()
# 初始化默认用户(只执行一次)
if not one(conn, "SELECT id FROM users LIMIT 1"):
_exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""",
("qiukai", generate_password_hash("yxcowork2026", "pbkdf2:sha256"), "qiukai", "admin", date.today().isoformat()))
_exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""",
("kepu", generate_password_hash("kepu123", "pbkdf2:sha256"), "科普负责人", "opc_owner", date.today().isoformat()))
_exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""",
("keyan", generate_password_hash("keyan123", "pbkdf2:sha256"), "科研负责人", "opc_owner", date.today().isoformat()))
_exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""",
("yihuan", generate_password_hash("yihuan123", "pbkdf2:sha256"), "医患负责人", "opc_owner", date.today().isoformat()))
# 各 OPC 负责人绑定工作台
for uname, tenant in [("kepu","科普·无界"),("keyan","科研·无界"),("yihuan","医患·无界")]:
u = one(conn, "SELECT id FROM users WHERE username=?", (uname,))
if u:
_exec(conn, "INSERT INTO user_tenants (user_id, tenant) VALUES (?,?)", (u["id"], tenant))
conn.commit()
if one(conn, "SELECT id FROM sales_leads LIMIT 1"): if one(conn, "SELECT id FROM sales_leads LIMIT 1"):
conn.close() conn.close()
return return
@@ -156,16 +353,16 @@ CREATE TABLE IF NOT EXISTS file_assets (
("天广实生物", "P1", "待跟进", "血液肿瘤医生教育机会。"), ("天广实生物", "P1", "待跟进", "血液肿瘤医生教育机会。"),
] ]
for customer, priority, status, note in sales: for customer, priority, status, note in sales:
cur = conn.execute( cur = _exec(conn,
"INSERT INTO sales_leads (target_customer, priority, status) VALUES (?,?,?)", "INSERT INTO sales_leads (target_customer, priority, status) VALUES (?,?,?)",
(customer, priority, status), (customer, priority, status),
) )
conn.execute( _exec(conn,
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)", "INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("sales", cur.lastrowid, date.today().isoformat(), note, "明确下一次沟通人和时间"), ("sales", cur.lastrowid, date.today().isoformat(), note, "明确下一次沟通人和时间"),
) )
cur = conn.execute( cur = _exec(conn,
"INSERT INTO business_proposals (customer_or_project_name,version,description,status,created_date) VALUES (?,?,?,?,?)", "INSERT INTO business_proposals (customer_or_project_name,version,description,status,created_date) VALUES (?,?,?,?,?)",
("信达生物", "v1.5", "信达科普项目续约与报价方案", "已提交客户", "2026-05-28"), ("信达生物", "v1.5", "信达科普项目续约与报价方案", "已提交客户", "2026-05-28"),
) )
@@ -187,14 +384,14 @@ CREATE TABLE IF NOT EXISTS file_assets (
] ]
op_dir = WEIXIN_BASE / "3、运营方案" op_dir = WEIXIN_BASE / "3、运营方案"
for name, version, kind, status, stage, progress, note in projects: for name, version, kind, status, stage, progress, note in projects:
cur = conn.execute( cur = _exec(conn,
"""INSERT INTO operation_projects """INSERT INTO operation_projects
(project_name,project_version,project_type,project_status,current_stage,target_customer,customer_need, (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) expected_contract_amount,expected_sign_date,sign_probability,next_action,sop_stage,execution_progress,current_deliverable)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(name, version, kind, status, stage, "圆心科技", "科普内容项目执行与管理", 0 if kind == "execution" else 200, "2026-06", 100 if kind == "execution" else 70, "补齐版本要求文件并更新下一节点", stage, progress, note), (name, version, kind, status, stage, "圆心科技", "科普内容项目执行与管理", 0 if kind == "execution" else 200, "2026-06", 100 if kind == "execution" else 70, "补齐版本要求文件并更新下一节点", stage, progress, note),
) )
conn.execute( _exec(conn,
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)", "INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("operation", cur.lastrowid, date.today().isoformat(), note, "补齐版本要求文件并更新下一节点"), ("operation", cur.lastrowid, date.today().isoformat(), note, "补齐版本要求文件并更新下一节点"),
) )
@@ -210,18 +407,18 @@ CREATE TABLE IF NOT EXISTS file_assets (
add_file_index(conn, "operation", project_id, version, category, op_dir / filename, external=True) add_file_index(conn, "operation", project_id, version, category, op_dir / filename, external=True)
products = [ products = [
("妙手医生服务小程序", "v1.1", "视频任务增强 + 积分商城", "草稿箱、批量上传、积分商城、消息通知", "2026-Q3", "规划中"), ("妙手医生服务小程序", "v1.1", "视频任务增强 + 积分商城", "草稿箱、批量上传、积分商城、消息通知", "2026-Q3", "规划中", "科普平台"),
("数字化营销后台管理系统", "v1.2", "运营数据看板 + 智能审核", "医生活跃、任务完成率、AI 预审、渠道数据上报", "2026-Q3", "设计中"), ("数字化营销后台管理系统", "v1.2", "运营数据看板 + 智能审核", "医生活跃、任务完成率、AI 预审、渠道数据上报", "2026-Q3", "设计中", "真研平台"),
("妙手患者服务", "v0.5", "科普浏览 + 医生主页 MVP", "科普文章/视频浏览、医生主页、搜索", "2026-Q3", "规划中"), ("妙手患者服务", "v0.5", "科普浏览 + 医生主页 MVP", "科普文章/视频浏览、医生主页、搜索", "2026-Q3", "规划中", "科普平台"),
("数字人内容平台", "v0.1", "基础数字人视频生成 MVP", "预设形象、AI 配音、脚本驱动、简单模板", "2026-Q3", "规划中"), ("数字人内容平台", "v0.1", "基础数字人视频生成 MVP", "预设形象、AI 配音、脚本驱动、简单模板", "2026-Q3", "规划中", "科普平台"),
("渠道分发引擎", "v1.0", "六渠道统一分发", "分发 API、内容适配、分发排期、效果追踪", "2027-Q1", "规划中"), ("渠道分发引擎", "v1.0", "六渠道统一分发", "分发 API、内容适配、分发排期、效果追踪", "2027-Q1", "规划中", "科普平台"),
] ]
for product in products: for product in products:
cur = conn.execute( cur = _exec(conn,
"INSERT INTO product_versions (product_name,version,version_goal,feature_list,launch_date,status) VALUES (?,?,?,?,?,?)", "INSERT INTO product_versions (product_name,version,version_goal,feature_list,launch_date,status,platform) VALUES (?,?,?,?,?,?,?)",
product, product,
) )
conn.execute( _exec(conn,
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)", "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]}", "按路线图推进"), ("product", cur.lastrowid, date.today().isoformat(), f"{product[0]} {product[1]}{product[2]}", "按路线图推进"),
) )
@@ -233,11 +430,30 @@ CREATE TABLE IF NOT EXISTS file_assets (
("2026-05", "cost_expense", "运营管理", 16, "项目管理与渠道协同"), ("2026-05", "cost_expense", "运营管理", 16, "项目管理与渠道协同"),
("2026-06", "cost_expense", "渠道分发", 24, "投放与分发费用"), ("2026-06", "cost_expense", "渠道分发", 24, "投放与分发费用"),
]: ]:
conn.execute( _exec(conn,
"INSERT INTO finance_records (month,record_type,category,amount,occurred_date,notes) VALUES (?,?,?,?,?,?)", "INSERT INTO finance_records (month,record_type,category,amount,occurred_date,notes) VALUES (?,?,?,?,?,?)",
(month, record_type, category, amount, f"{month}-01", notes), (month, record_type, category, amount, f"{month}-01", notes),
) )
# Seed project tasks for 信达科普文章项目 (project_id=1)
tasks_seed = [
("阶段1渠道与商务确认", "商务对接", "合同签订", "Anna", "2026-06-30", "法务审核中", "合同签订后开始执行"),
("阶段1渠道与商务确认", "官媒渠道确认", "沟通官媒确定", "段丽华", "2026-06-30", "官媒尽力推,以先达成合作为准", "集团支持"),
("阶段1渠道与商务确认", "官媒渠道确认", "官媒合作签约", "段丽华", "2026-06-18", "", "官媒确认细节"),
("阶段2系统与标准搭建", "系统开发上线", "音频专访系统开发上线", "戴敏/梁军营", "2026-06-18", "客户比较着急执行,需要技术的资源", ""),
("阶段2系统与标准搭建", "系统开发上线", "精品视频系统开发上线", "戴敏/梁军营", "2026-06-25", "", ""),
("阶段2系统与标准搭建", "标准与培训", "业务执行手册SOP", "胡龙飞", "2026-06-12", "", "系统开发上线"),
("阶段3人员与审核入驻", "团队组建", "医学审核人员到位", "胡龙飞", "2026-06-15", "", "审核人员招聘"),
("阶段3人员与审核入驻", "团队组建", "视频制作人员到位", "胡龙飞", "2026-06-18", "", "项目经理招聘"),
("阶段4供应链与制作", "供应商准入", "准入拍摄/剪辑/主持人", "胡龙飞/侯亚凤", "2026-06-18", "", ""),
("阶段2系统与标准搭建", "脚本生产及审核", "生产脚本", "军营", "2026-06-12", "脚本目前生产比较机械,需要提前准备", "细分标签领域完成"),
]
for phase, milestone, task, owner, due_date, blockers, notes in tasks_seed:
_exec(conn,
"INSERT INTO project_tasks (project_id,phase,milestone,task,owner,due_date,blockers,notes) VALUES (?,?,?,?,?,?,?,?)",
(1, phase, milestone, task, owner, due_date, blockers, notes),
)
conn.commit() conn.commit()
conn.close() conn.close()
@@ -246,7 +462,7 @@ def add_file_index(conn, module, owner_id, owner_version, category, path, extern
path = Path(path) path = Path(path)
if not path.exists(): if not path.exists():
return return
conn.execute( _exec(conn,
"""INSERT INTO file_assets """INSERT INTO file_assets
(module,owner_id,owner_version,file_category,file_name,file_type,file_size,file_path,is_external) (module,owner_id,owner_version,file_category,file_name,file_type,file_size,file_path,is_external)
VALUES (?,?,?,?,?,?,?,?,?)""", VALUES (?,?,?,?,?,?,?,?,?)""",
@@ -280,33 +496,88 @@ def attach_common(conn, resource, items):
return items return items
def monthly_finance(conn): def monthly_finance(conn, tenant="科普·无界"):
from datetime import date
today = date.today()
# 6 months: 3 before + current + 2 after
from dateutil.relativedelta import relativedelta
start = today + relativedelta(months=-3)
months = []
for i in range(6):
m = start + relativedelta(months=i)
months.append(m.strftime("%Y-%m"))
data = [] data = []
for item in rows(conn, "SELECT DISTINCT month FROM finance_records ORDER BY month"): for month in months:
month = item["month"] col_month = month.replace("-", "_")
revenue = one(conn, "SELECT COALESCE(SUM(amount),0) AS v FROM finance_records WHERE month=? AND record_type='revenue'", (month,))["v"] col_rev = f"rev_{col_month}"
cost = one(conn, "SELECT COALESCE(SUM(amount),0) AS v FROM finance_records WHERE month=? AND record_type='cost_expense'", (month,))["v"] col_gross = f"gross_{col_month}"
data.append({"month": month, "revenue": revenue, "gross_profit": revenue - cost, "cost_expense": cost, "net_profit": revenue - cost}) # Only project_finances has columns for 2026-06 through 2026-09
if month in ["2026-06", "2026-07", "2026-08", "2026-09"]:
revenue = one(conn, f"SELECT COALESCE(SUM({col_rev}),0) AS v FROM project_finances WHERE tenant=?", (tenant,))["v"]
gross = one(conn, f"SELECT COALESCE(SUM({col_gross}),0) AS v FROM project_finances WHERE tenant=?", (tenant,))["v"]
else:
revenue = 0
gross = 0
data.append({
"month": month, "revenue": revenue,
"labor": 0, "expense": 0, "purchase": 0,
"net_profit": gross,
})
return data return data
@app.route("/") @app.route("/")
def index(): def index():
if "user_id" not in session:
return redirect("/login")
return render_template("index.html") return render_template("index.html")
@app.route("/api/bootstrap") @app.route("/api/bootstrap")
def bootstrap(): def bootstrap():
if "user_id" not in session:
return jsonify({"error": "未登录"}), 401
tenant = request.args.get("tenant", session.get("tenants", ["科普·无界"])[0])
# 验证用户是否有权限访问该 workbench
allowed = session.get("tenants", [])
if tenant not in allowed:
tenant = allowed[0]
conn = db() conn = db()
try: try:
sales = attach_common(conn, "sales", rows(conn, "SELECT * FROM sales_leads ORDER BY id DESC")) def q(sql, *args):
proposals = attach_common(conn, "proposals", rows(conn, "SELECT * FROM business_proposals ORDER BY id DESC")) return rows(conn, sql, args)
operations = attach_common(conn, "operations", rows(conn, "SELECT * FROM operation_projects ORDER BY id DESC")) sales = attach_common(conn, "sales", q("SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", tenant))
products = attach_common(conn, "products", rows(conn, "SELECT * FROM product_versions ORDER BY id DESC")) proposals = attach_common(conn, "proposals", q("SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", tenant))
finance = rows(conn, "SELECT * FROM finance_records ORDER BY month DESC, id DESC") operations = attach_common(conn, "operations", q("SELECT * FROM operation_projects WHERE tenant=? ORDER BY id DESC", tenant))
current_month = "2026-05" products = attach_common(conn, "products", q("SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", tenant))
revenue = sum(x["amount"] for x in finance if x["month"] == current_month and x["record_type"] == "revenue") finance = q("SELECT * FROM finance_records WHERE tenant=? ORDER BY month DESC, id DESC", tenant)
cost = sum(x["amount"] for x in finance if x["month"] == current_month and x["record_type"] == "cost_expense") tasks = q("SELECT * FROM project_tasks WHERE tenant=? ORDER BY phase, sort_order, id", tenant)
pfs = q("SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", tenant)
current_month = "2026-06"
# Finance aggregates — from project_finances (project-based)
def pf_sum(field):
return sum(x[field] or 0 for x in pfs)
rev_month = pf_sum("rev_2026_06")
gross_month = pf_sum("gross_2026_06")
rev_q2 = pf_sum("rev_2026_06")
gross_q2 = pf_sum("gross_2026_06")
rev_annual = rev_q2
gross_annual = gross_q2
# Contract aggregates — time-based
signed_amount = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_status"] == "已签约")
from datetime import date
today = date.today()
def contract_in_period(op, start, end):
if op["project_status"] != "已签约": return False
try:
d = date.fromisoformat(op["created_at"][:10])
return start <= d <= end
except: return False
signed_annual = sum(x["expected_contract_amount"] or 0 for x in operations if contract_in_period(x, date(2026,1,1), date(2026,12,31)))
signed_q2 = sum(x["expected_contract_amount"] or 0 for x in operations if contract_in_period(x, date(2026,4,1), date(2026,6,30)))
signed_month = sum(x["expected_contract_amount"] or 0 for x in operations if contract_in_period(x, date(2026,6,1), date(2026,6,30)))
pipeline_amount = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_status"] not in ["已签约","已丢单","已归档","已完成"])
signed_not_executed = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_type"] == "execution" and x["execution_progress"] < 100)
summary = { summary = {
"project_name": "科普(慰心斋)", "project_name": "科普(慰心斋)",
"metrics": { "metrics": {
@@ -314,28 +585,46 @@ def bootstrap():
"active_sales": len([x for x in sales if x["status"] in ["待跟进", "跟进中", "方案中", "商务谈判"]]), "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"]), "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"]]), "risk_projects": len([x for x in operations if x["project_status"] == "有风险" or x["risks"]]),
"monthly_revenue": revenue, "monthly_revenue": rev_month,
"monthly_net_profit": revenue - cost, "monthly_net_profit": gross_month,
"monthly_gross": gross_month,
"upcoming_products": len([x for x in products if x["status"] in ["规划中", "设计中", "开发中", "测试中"]]), "upcoming_products": len([x for x in products if x["status"] in ["规划中", "设计中", "开发中", "测试中"]]),
"total_projects": len(operations),
"total_proposals": len(proposals),
"total_products": len(products),
# Extended finance metrics
"signed_amount": signed_amount,
"signed_annual": signed_annual,
"signed_q2": signed_q2,
"signed_month": signed_month,
"pipeline_amount": pipeline_amount,
"revenue_annual": rev_annual,
"revenue_q2": rev_q2,
"gross_annual": gross_annual,
"gross_q2": gross_q2,
"signed_not_executed": signed_not_executed,
}, },
"recent": rows(conn, "SELECT * FROM follow_up_records ORDER BY id DESC LIMIT 8"), "recent": q("SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 8", tenant),
"risks": [{"title": "执行提醒", "content": x["next_action"]} for x in operations if x["next_action"]][:5], "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)}) return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "projectFinances": pfs, "financeMonthly": monthly_finance(conn, tenant), "tasks": tasks, "tenant": tenant, "tenants": allowed})
finally: finally:
conn.close() conn.close()
TABLES = { TABLES = {
"sales": ("sales_leads", ["target_customer", "priority", "status"]), "sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]),
"proposals": ("business_proposals", ["customer_or_project_name", "version", "description", "status", "created_date"]), "proposals": ("business_proposals", ["customer_or_project_name", "version", "description", "status", "created_date", "proposal_type", "notes", "tenant"]),
"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"]), "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", "tenant"]),
"products": ("product_versions", ["product_name", "version", "version_goal", "feature_list", "launch_date", "status", "notes"]), "products": ("product_versions", ["product_name", "version", "version_goal", "feature_list", "launch_date", "status", "platform", "notes", "tenant"]),
"finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes"]), "finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes", "tenant"]),
"tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "priority", "tenant"]),
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "total_rev", "total_gross", "budget_data"]),
} }
@app.route("/api/<resource>", methods=["POST"]) @app.route("/api/<resource>", methods=["POST"])
@login_required
def create_resource(resource): def create_resource(resource):
if resource not in TABLES: if resource not in TABLES:
return jsonify({"error": "unknown resource"}), 404 return jsonify({"error": "unknown resource"}), 404
@@ -344,7 +633,7 @@ def create_resource(resource):
values = [payload.get(col, "") for col in cols] values = [payload.get(col, "") for col in cols]
conn = db() conn = db()
try: try:
cur = conn.execute(f"INSERT INTO {table} ({','.join(cols)}) VALUES ({','.join(['?'] * len(cols))})", values) cur = _exec(conn, f"INSERT INTO {table} ({','.join(cols)}) VALUES ({','.join(['?'] * len(cols))})", values)
conn.commit() conn.commit()
return jsonify({"id": cur.lastrowid}) return jsonify({"id": cur.lastrowid})
finally: finally:
@@ -352,6 +641,7 @@ def create_resource(resource):
@app.route("/api/<resource>/<int:item_id>", methods=["PUT", "DELETE"]) @app.route("/api/<resource>/<int:item_id>", methods=["PUT", "DELETE"])
@login_required
def update_resource(resource, item_id): def update_resource(resource, item_id):
if resource not in TABLES: if resource not in TABLES:
return jsonify({"error": "unknown resource"}), 404 return jsonify({"error": "unknown resource"}), 404
@@ -359,13 +649,13 @@ def update_resource(resource, item_id):
conn = db() conn = db()
try: try:
if request.method == "DELETE": if request.method == "DELETE":
conn.execute(f"DELETE FROM {table} WHERE id=?", (item_id,)) _exec(conn, f"DELETE FROM {table} WHERE id=?", (item_id,))
conn.commit() conn.commit()
return jsonify({"ok": True}) return jsonify({"ok": True})
payload = request.get_json(force=True).get("data", {}) payload = request.get_json(force=True).get("data", {})
update_cols = [col for col in cols if col in payload] update_cols = [col for col in cols if col in payload]
if update_cols: if update_cols:
conn.execute( _exec(conn,
f"UPDATE {table} SET {','.join([col + '=?' for col in update_cols])}, updated_at=? WHERE id=?", 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], [payload[col] for col in update_cols] + [now(), item_id],
) )
@@ -376,14 +666,15 @@ def update_resource(resource, item_id):
@app.route("/api/followups/<target_type>/<int:target_id>", methods=["POST"]) @app.route("/api/followups/<target_type>/<int:target_id>", methods=["POST"])
@login_required
def add_followup(target_type, target_id): def add_followup(target_type, target_id):
payload = request.get_json(force=True).get("data", {}) payload = request.get_json(force=True).get("data", {})
conn = db() conn = db()
try: try:
conn.execute( _exec(conn,
"""INSERT INTO follow_up_records """INSERT INTO follow_up_records
(target_type,target_id,followed_at,follower,follow_up_method,content,next_action,next_follow_up_at) (target_type,target_id,followed_at,follower,follow_up_method,content,next_action,next_follow_up_at,tenant)
VALUES (?,?,?,?,?,?,?,?)""", VALUES (?,?,?,?,?,?,?,?,?)""",
( (
target_type, target_type,
target_id, target_id,
@@ -393,6 +684,7 @@ def add_followup(target_type, target_id):
payload.get("content") or "", payload.get("content") or "",
payload.get("next_action") or "", payload.get("next_action") or "",
payload.get("next_follow_up_at") or "", payload.get("next_follow_up_at") or "",
payload.get("tenant") or "科普·无界",
), ),
) )
conn.commit() conn.commit()
@@ -402,10 +694,11 @@ def add_followup(target_type, target_id):
@app.route("/api/followups/<int:followup_id>", methods=["DELETE"]) @app.route("/api/followups/<int:followup_id>", methods=["DELETE"])
@login_required
def delete_followup(followup_id): def delete_followup(followup_id):
conn = db() conn = db()
try: try:
cur = conn.execute("DELETE FROM follow_up_records WHERE id=?", (followup_id,)) cur = _exec(conn, "DELETE FROM follow_up_records WHERE id=?", (followup_id,))
conn.commit() conn.commit()
if cur.rowcount == 0: if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404 return jsonify({"error": "not found"}), 404
@@ -414,7 +707,22 @@ def delete_followup(followup_id):
conn.close() conn.close()
@app.route("/api/tasks/batch-sort", methods=["POST"])
@login_required
def batch_sort_tasks():
conn = db()
try:
items = request.get_json(force=True).get("items", [])
for item in items:
_exec(conn, "UPDATE project_tasks SET sort_order=? WHERE id=?", (item["sort_order"], item["id"]))
conn.commit()
return jsonify({"ok": True})
finally:
conn.close()
@app.route("/api/files/upload", methods=["POST"]) @app.route("/api/files/upload", methods=["POST"])
@login_required
def upload_file(): def upload_file():
file = request.files["file"] file = request.files["file"]
module = request.form["module"] module = request.form["module"]
@@ -450,6 +758,7 @@ def file_content(file_id):
@app.route("/api/files/<int:file_id>", methods=["DELETE"]) @app.route("/api/files/<int:file_id>", methods=["DELETE"])
@login_required
def delete_file(file_id): def delete_file(file_id):
conn = db() conn = db()
try: try:
@@ -460,7 +769,7 @@ def delete_file(file_id):
path = Path(asset["file_path"]) path = Path(asset["file_path"])
if path.exists() and str(UPLOAD_DIR) in str(path.resolve()): if path.exists() and str(UPLOAD_DIR) in str(path.resolve()):
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
conn.execute("DELETE FROM file_assets WHERE id=?", (file_id,)) _exec(conn, "DELETE FROM file_assets WHERE id=?", (file_id,))
conn.commit() conn.commit()
return jsonify({"ok": True}) return jsonify({"ok": True})
finally: finally:

View File

@@ -1 +1,2 @@
Flask==3.0.3 Flask==3.0.3
python-dateutil==2.9.0

153
docs/prd/CHANGELOG.md Normal file
View File

@@ -0,0 +1,153 @@
# OPC 管理系统 PRD 修改日志
---
## v0.9 → v1.02026-06-01
### 版本升级
- PRD 阶段从「需求核对稿」升级为「实际落地版」
- 版本号从 v0.9 → v1.0
- 匹配 OPC-Manager v1.0.2 代码实现
### 功能布局变更
| 模块 | v0.9 | v1.0 | 原因 |
|------|------|------|------|
| 业务方案 Tab | 一个版本一个**卡片** | 表格布局 | 与销售、运营保持一致的交互模式,表格更适合多版本扫描 |
| 产品 Tab | 一个版本一个**卡片** | 表格布局 | 同上,统一交互体验 |
### 交互细节补充
| 项 | v0.9 | v1.0 |
|----|------|------|
| 抽屉宽度 | 未指定 | 720px 固定宽度 |
| 保存方式 | "关闭抽屉时提示保存" | 字段失焦自动保存 + 状态反馈(保存中/已保存/失败) |
| 抽屉外点击关闭 | 未明确 | 已删除此交互 |
| 富文本编辑器 | 未指定 | Squire RTE v1Fastmail |
| 评论格式 | 未指定 | HTML 格式encodeURIComponent 存储decodeURIComponent 渲染) |
| 评论删除 | 未指定 | 所有评论 hover 显示删除图标,带确认弹窗 |
| Squire 工具栏 | 未指定 | 使用 onmousedown 防止编辑器失焦 |
| 列表渲染 | 未指定 | .rich-content ul/ol 使用 list-style: revert 覆盖 Tailwind reset |
### 数据模型变更
| 项 | v0.9 | v1.0 |
|----|------|------|
| FileAsset | operation_files 独立表 | 统一 file_assets 表,通过 module 字段区分 proposal/operation |
| FileAsset.is_external | 无 | 新增字段标记原始文件索引1vs 本地上传0 |
| FollowUpRecord.content | 文本 | HTML 格式 |
### 技术栈补充
| 项 | v0.9 | v1.0 |
|----|------|------|
| 富文本编辑器 | 未指定 | Squire RTEsquire-rte CDN v1build/squire-raw.js |
| Chart.js 版本 | 未指定 | 4.4.8 |
| Chart 配置 | 未指定 | maintainAspectRatio: false + 固定容器高度 140px |
| Flask 版本 | 未指定 | 3.1.3 |
| 项目结构 | 未列出 | 新增 7.6 节完整目录结构 |
### 章节结构变更
| 项 | v0.9 | v1.0 |
|----|------|------|
| 首页指标 | 6 项 | 7 项(新增"即将上线版本数" |
| 首页指标布局 | 未指定 | 4 列网格grid-cols-4自动换行 |
| 设计规范 | 大量引用 ui-ux-pro-max-skill/frontend-design | 精简为实际的视觉规范表格 + 设计辅助工具已使用说明 |
| 版本历史 | 无 | 新增第 12 章版本历史 + 关联 CHANGELOG |
---
## 文件存档
| 文件 | 路径 |
|------|------|
| v0.9 原始 PRD | `docs/prd-history/OPC管理系统PRD-V0.9.md` |
| v1.0 当前 PRD | `OPC管理系统PRD.md`(根目录) |
| 修改日志(本文件) | `docs/prd/CHANGELOG.md` |
---
## v1.0 → v2.02026-06-16
### 版本升级
- PRD 匹配 OPC-Manager v1.7.6 代码实现
- 新增 `OPC管理系统PRD-V2.0.md`
### 架构级变更
| 模块 | v1.0 | v2.0 | 原因 |
|------|------|------|------|
| 销售管理 + 运营管理 | 两个独立 Tab | 合并为「重点项目」Tab | 业务机会到执行是同一项目的不同阶段 |
| 任务追踪 | 无 | 6 阶段任务体系 + project_tasks 表 | 参考科普项目 Excel 台账管理 |
| 任务详情 | 无 | 二级页面Tab 内渲染) | 替代弹窗,完整页面体验 |
| 任务编辑 | 无 | 右侧抽屉fixed 定位420px | 不打断列表浏览 |
| 首页指标 | 7 项 | 14 项(两层:经营核心 + 财务分层) | 增加合同金额、年度/Q2 财务汇总 |
### Tab 数量变更
| v1.0 | v2.0 |
|------|------|
| 首页、销售管理、业务方案、运营管理、产品研发、财务管理6 Tab | 首页、重点项目、业务方案、产品研发、财务管理5 Tab |
### 重点项目详细变更
| 项 | v1.0 | v2.0 |
|----|------|------|
| 列表列 | 项目名称/类型/状态/金额/阶段/文件/跟进7 列) | 项目/说明/阶段/金额/负责人/进展6 列) |
| 筛选按钮 | 全部/业务机会/已签约执行 | 已去除,列表不做阶段筛选 |
| 抽屉字段 | 14 个字段 | 6 个字段(名称/阶段/金额/截止/负责人/说明)+ 删除按钮 |
| 当前阶段 | 文本输入 | 6 阶段下拉框change 自动保存) |
| 阶段体系 | 无标准 | 商务洽谈→系统上线→团队分工→项目交付→上线推广→结项验收 |
| 版本号 | 项目名称含版本号 | 去除,仅保留项目名称 |
### 新增数据表
**project_tasks**
| 字段 | 类型 | 说明 |
|------|------|------|
| project_id | INTEGER | 关联 operation_projects |
| phase | TEXT | 6 阶段之一 |
| milestone | TEXT | 里程碑 |
| task | TEXT | 任务名称 |
| owner | TEXT | 负责人 |
| due_date | TEXT | 截止时间 |
| blockers | TEXT | 卡点&备注 |
| notes | TEXT | 任务说明 |
### 产品 Tab 变更
| 项 | v1.0 | v2.0 |
|----|------|------|
| 平台字段 | 无 | 新增 platform 字段(真研平台/科普平台/关爱平台) |
| 列表列 | 产品/版本/目标/功能/日期/状态6 列) | 产品/版本/目标/功能/平台/日期/状态7 列) |
### 聚合指标变更
| 指标 | v1.0 | v2.0 |
|------|------|------|
| P0 客户数 | ✓ | ✓ |
| 跟进中销售机会 | ✓ | ✓ |
| 已签约执行项目 | ✓ | ✓ |
| 有风险项目 | ✓ | ✓ |
| 本月收入 | ✓ | ✓ |
| 本月净利 | ✓ | ✓ |
| 即将上线版本 | ✓ | ✓ |
| 已签约未执行金额 | — | ✓ 新增 |
| 已签约合同总额 | — | ✓ 新增 |
| 合同流程中金额 | — | ✓ 新增 |
| 年度累计确收 | — | ✓ 新增 |
| Q2 累计确收 | — | ✓ 新增 |
| 年度累计毛利 | — | ✓ 新增 |
| Q2 累计毛利 | — | ✓ 新增 |
### 技术实现变更
| 项 | v1.0 | v2.0 |
|----|------|------|
| 前端交互 | 弹窗 modal | Tab 内二级页面state.projectView |
| 任务编辑 | 无 | fixed 抽屉 + submitTaskForm/openTaskForm |
| 自动保存 | blur 事件 | blurinput+ changeselect |
| Lucide 刷新 | render() 统一调用 | 各 render 函数独立调用 createIcons() |

View File

@@ -0,0 +1,918 @@
# OPC 管理系统 PRD
版本v0.9
日期2026-05-29
阶段:需求核对稿
定位:以科普(慰心斋)为单项目独立版本,打造 OPC 工作台,从全局首页、销售、业务、运营、产品、财务 6 个视角,直观、清晰、全面地管理业务全貌。
---
## 1. 背景与目标
### 1.1 需求原文
以慰心斋为例,打造一个 OPC 工作台,让我能够直观地看到他销售、业务、运营、产品、财务的情况。另外,之前他为自己单独打造了一个财务体系,把这个财务 manager 系统直接合并进工作台里面,不再作为一个独立的系统。
### 1.2 背景
当前业务信息分散在销售表、方案文件、运营记录、产品路线图、财务台账等多个位置,难以一眼判断业务进展、交付风险、产品节奏和财务结果。
OPC 管理系统用于建立一个统一工作台,让负责人可以通过全局首页和 5 个业务 Tab 完成日常管理:
1. 全局首页:集中展示科普(慰心斋)的经营关键指标和风险提醒。
2. 销售管理:看清目标客户、优先级、状态和多条时间线跟进记录。
3. 业务方案:按版本管理售前/商务阶段的方案、成本、SOP、财务流程等文件。
4. 运营管理:承接交付的实际管理形态,将项目分为业务机会项目和已签约执行项目,分别看清签约推进和 SOP 执行过程。
5. 产品管理:按版本管理产品目标、核心功能、上线日期和版本跟进。
6. 财务管理:用曲线图和明细表查看收入、毛利、成本/费用、净利。
本系统将承接并合并原有独立财务 manager 能力,财务不再作为单独系统存在,而是作为 OPC 管理系统中的一个 Tab。
### 1.3 样板范围
v0.9 以科普(慰心斋)作为首个单项目独立版本,优先接入和整理慰心斋已有资料:
- 销售资料:`/Users/mac/天机阁/地阁/慰心斋/1、销售管理`
- 业务方案:`/Users/mac/天机阁/地阁/慰心斋/2、业务方案`
- 运营资料:`/Users/mac/天机阁/地阁/慰心斋/3、运营方案`
- 产品资料:`/Users/mac/天机阁/地阁/慰心斋/4、产品方案`
- 财务系统:`/Users/mac/天机阁/地阁/慰心斋/5、财务管理/mananger`
首版只做单项目、单用户版本,不做多项目、多用户、权限体系。系统形态应保留后续复制到其他业务单元或升级为多项目/多用户的扩展能力,但首版数据、页面样例和迁移逻辑以科普(慰心斋)为准。
---
## 2. 用户与使用场景
### 2.1 核心用户
- 业务负责人:查看全局进展,识别销售、运营、产品、财务风险。
- 销售负责人:维护目标客户和跟进记录。
- 项目/运营负责人:维护业务机会推进、已签约项目 SOP 执行、交付文件和项目进展。
- 产品负责人:维护版本规划、上线日期和功能清单。
- 财务负责人:维护收入、成本/费用、毛利、净利明细。
### 2.2 高频场景
- 每周例会前,先通过全局首页查看销售 pipeline、项目运营状态、产品版本进度、财务结果和风险提醒。
- 新增客户后,录入客户优先级、当前状态和跟进记录。
- 一个业务方案形成新版本后上传方案、成本、SOP、财务流程文件。
- 项目处于机会阶段时,在运营 Tab 中跟进签约推进;项目签约后,在运营 Tab 中按 SOP 跟进执行过程和交付文件。
- 产品版本推进时,维护版本目标、核心功能、上线日期和跟进记录。
- 财务复盘时,查看收入、毛利、成本/费用、净利月度趋势和明细。
---
## 3. 信息架构
系统顶部为全局标题和基础操作区,主体为 6 个一级 Tab
1. 首页
2. 销售管理
3. 业务方案
4. 运营
5. 产品
6. 财务
所有 Tab 均支持基础的新增、查看、编辑能力。删除能力作为后台管理能力保留,默认不突出展示,避免误删关键业务数据。
---
## 4. 功能需求
### 4.1 首页 Tab
首页是科普(慰心斋)单项目的经营总览页,用于在进入具体 Tab 前先看到整体情况。
#### 页面形式
- 首页由关键指标卡、风险提醒、近期动态和趋势概览组成。
- 首页展示的是销售、业务、运营、产品、财务五个模块的聚合信息,不承载复杂编辑。
#### 关键指标
- P0 客户数
- 跟进中销售机会数
- 已签约执行项目数
- 有风险执行项目数
- 本月收入
- 本月净利
- 即将上线产品版本数
#### 风险提醒
- 预计签约时间临近但状态未推进的业务机会项目。
- 已签约执行项目中标记为有风险或存在阻塞的项目。
- 产品上线日期临近但状态未进入测试/已上线的版本。
- 当月净利为负或成本/费用异常升高的月份。
#### 近期动态
- 最近新增的销售跟进记录。
- 最近更新的运营项目跟进记录。
- 最近上传或更新的业务方案/运营交付文件。
- 最近新增的财务明细。
#### 交互要求
- 首页指标点击后可跳转到对应 Tab 并带入筛选条件。
- 首页只做查看和跳转,不在首页直接编辑业务数据。
---
### 4.2 销售管理 Tab
#### 页面形式
- 内容按表格形式展示。
- 表格字段:
- 目标客户
- 优先级
- 状态
- 最新跟进记录
#### 交互要求
- 用户可以新增销售线索/目标客户。
- 用户点击表格中的某一行后,进入详情查看与编辑。
- 详情中可以查看并编辑:
- 目标客户
- 优先级
- 状态
- 跟进记录必须是多条时间线记录,不使用单一文本字段。
- 用户可以在详情中新增、编辑、删除每条跟进记录。
#### 跟进时间线字段
- 跟进时间
- 跟进人
- 跟进方式
- 跟进内容
- 下一步动作
- 下次跟进时间
#### 推荐状态字段
- 待跟进
- 跟进中
- 方案中
- 商务谈判
- 已签约
- 暂缓
- 已丢单
#### 推荐优先级字段
- P0
- P1
- P2
- P3
---
### 4.3 业务方案 Tab
#### 页面形式
- 一个版本一个卡片。
- 卡片标题建议格式:客户/项目名称 + 版本号,例如「信达生物 v1.5」。
#### 每个版本卡片包含 4 个要素
1. 方案
2. 成本
3. SOP
4. 财务流程
#### 文件规则
- 一个要素对应一个文件分组。
- 每个文件分组允许上传多个文件。
- 文件类型不限于:
- Word
- Excel
- PPT
- PDF
- 图片
- 压缩包
#### 交互要求
- 用户可以新增一个业务方案版本卡片。
- 用户可以为卡片上传方案、成本、SOP、财务流程文件。
- 用户可以查看每个要素下已上传的文件列表。
- 用户可以替换、追加或删除文件。
- 用户可以在线预览已上传文件。PDF 和图片应优先支持浏览器内预览Word、Excel、PPT 可优先支持下载,若技术条件允许再支持在线预览或转换预览。
- 用户可以编辑版本基础信息:
- 客户/项目名称
- 版本号
- 版本说明
- 创建日期
- 当前状态
#### 推荐版本状态
- 草稿
- 内部评审
- 已提交客户
- 客户反馈中
- 已确认
- 已归档
---
### 4.4 运营 Tab
运营是项目从机会推进到签约交付的实际承载形式。系统不单独设置“交付 Tab”交付过程、交付文件、项目执行状态统一归入运营 Tab 管理。
运营项目分为两类:
1. 业务机会项目:尚未签约,以推动签约为核心目标,重点展示客户意向、签约推进状态、下一步动作和跟进记录。
2. 已签约执行项目:已经签约,以按 SOP 交付为核心目标,重点展示 SOP 阶段、执行进度、交付物、风险和跟进记录。
首批运营文件来源:`/Users/mac/天机阁/地阁/慰心斋/3、运营方案`。当前已存在的文件按版本要求补充到对应运营项目,不建立“未归类文件池”。当前已存在的文件包括:
- 圆心科技--科普文章项目(1).pptx
- 圆心科技-科普专访项目-2026年(1).pdf
- 圆心科技-科普视频项目(1).pptx
- 圆心科技《项目管理手册》-2026年.pdf
- 科普项目-审核标准(文章-视频-音频).pdf
#### 页面形式
- 按表格形式展示。
- 表格顶部提供项目类型筛选:
- 业务机会项目
- 已签约执行项目
- 全部项目
- 表格字段:
- 项目名称
- 项目类型
- 项目状态
- 当前阶段
- 交付文件
- 跟进记录
#### 交互要求
- 用户可以新增运营项目。
- 新增项目时必须选择项目类型:业务机会项目或已签约执行项目。
- 用户点击表格中的某一行后,以抽屉形式展开。
- 抽屉内可以查看和编辑项目详情。
- 抽屉内可以上传、在线预览、下载和管理项目相关运营/交付文件。
- 运营文件必须先归属到明确项目版本,再根据该版本要求补充文件。
- 已放入运营管理文件夹的文件,需要作为对应项目版本的初始文件导入或索引到运营 Tab。
#### 抽屉详情字段
- 项目名称
- 项目版本
- 项目类型
- 项目状态
- 当前阶段
- 负责人
- 起止时间
- 交付文件列表
- 跟进记录
- 备注
#### 业务机会项目详情字段
- 目标客户
- 业务机会名称
- 客户需求
- 预计签约金额
- 预计签约时间
- 签约概率
- 当前推进阶段
- 下一步动作
- 跟进记录
- 关联业务方案
#### 已签约执行项目详情字段
- 客户名称
- 合同/项目名称
- SOP 模板或 SOP 文件
- 当前 SOP 阶段
- 执行进度
- 当前交付物
- 风险与阻塞
- 下一步动作
- 跟进记录
- 交付文件列表
#### 交付文件字段
- 文件名称
- 文件类型
- 所属项目
- 所属项目版本
- 文件分类
- 文件路径
- 上传时间
- 备注
#### 推荐文件分类
- 项目方案
- 项目管理手册
- 审核标准
- 执行 SOP
- 交付物
- 复盘报告
#### 推荐项目类型
- 业务机会项目
- 已签约执行项目
#### 业务机会项目推荐状态
- 线索发现
- 需求沟通
- 方案准备
- 方案已提交
- 商务谈判
- 待签约
- 已签约
- 暂缓
- 已丢单
#### 已签约执行项目推荐状态
- 未启动
- SOP 执行中
- 有风险
- 待客户确认
- 已完成
- 已归档
#### SOP 阶段建议
- 项目启动
- 需求确认
- 方案确认
- 内容生产
- 审核确认
- 渠道分发
- 数据/效果回收
- 复盘验收
---
### 4.5 产品 Tab
#### 页面形式
- 一个版本一个卡片。
- 卡片字段:
- 版本目标
- 核心功能清单
- 上线日期
- 跟进记录
#### 交互要求
- 用户可以新增产品版本。
- 用户点击版本卡片后,以抽屉形式展开查看版本详情。
- 用户可以在抽屉中修改对应内容。
#### 抽屉详情字段
- 产品名称
- 版本号
- 版本目标
- 核心功能清单
- 上线日期
- 当前状态
- 跟进记录
- 备注
#### 推荐版本状态
- 规划中
- 设计中
- 开发中
- 测试中
- 已上线
- 已延期
- 已取消
---
### 4.6 财务 Tab
#### 页面结构
财务 Tab 分为趋势图区域和明细区域。
#### 趋势图
需要展示 4 条月度曲线:
1. 收入按月份的曲线图
2. 毛利按月份的曲线图
3. 成本/费用按月份的曲线图
4. 净利按月份的曲线图
#### 明细表
每个指标需要对应明细:
1. 收入明细
2. 毛利明细
3. 成本/费用明细
4. 净利明细
#### 推荐财务口径
- 收入:当月确认收入金额。
- 成本/费用:统一录入为成本/费用,再通过类型区分直接成本、期间费用、运营费用、管理费用等。
- 毛利:收入 - 成本。
- 净利:收入 - 成本/费用。
- 月份统计按自然月,月份格式为 YYYY-MM。
#### 明细字段
收入明细:
- 月份
- 客户/项目
- 收入类型
- 金额
- 确认日期
- 备注
成本/费用明细:
- 月份
- 客户/项目
- 类型
- 金额
- 发生日期
- 备注
净利明细:
- 月份
- 收入合计
- 成本/费用合计
- 毛利
- 净利
---
## 5. 数据对象
### 5.1 SalesLead 销售线索
- id
- target_customer
- priority
- status
- latest_follow_up_record
- created_at
- updated_at
### 5.2 FollowUpRecord 跟进时间线记录
- id
- target_type
- target_id
- followed_at
- follower
- follow_up_method
- content
- next_action
- next_follow_up_at
- created_at
- updated_at
target_type 包括:
- sales销售线索
- operation运营项目
- product产品版本
### 5.3 BusinessProposal 业务方案版本
- id
- customer_or_project_name
- version
- description
- status
- created_date
- files
- created_at
- updated_at
files 按要素分组:
- proposal_files
- cost_files
- sop_files
- finance_process_files
### 5.4 OperationProject 运营项目
- id
- project_name
- project_version
- project_type
- project_status
- current_stage
- owner
- start_date
- end_date
- target_customer
- customer_need
- expected_contract_amount
- expected_sign_date
- sign_probability
- next_action
- related_business_proposal_id
- sop_file_id
- sop_stage
- execution_progress
- current_deliverable
- risks
- delivery_files
- follow_up_record
- notes
- created_at
- updated_at
project_type 包括:
- opportunity业务机会项目
- execution已签约执行项目
说明:
- 当 project_type = opportunity 时,重点使用目标客户、客户需求、预计签约金额、预计签约时间、签约概率、下一步动作、关联业务方案等字段。
- 当 project_type = execution 时,重点使用 SOP 文件、SOP 阶段、执行进度、当前交付物、风险、交付文件等字段。
### 5.5 OperationFile 运营/交付文件
- id
- project_id
- project_version
- file_name
- file_type
- file_category
- file_path
- uploaded_at
- notes
### 5.6 ProductVersion 产品版本
- id
- product_name
- version
- version_goal
- feature_list
- launch_date
- status
- follow_up_record
- notes
- created_at
- updated_at
### 5.7 FinanceRecord 财务记录
- id
- month
- project_name
- record_type
- category
- amount
- occurred_date
- notes
- created_at
- updated_at
record_type 包括:
- revenue
- cost_expense
month 采用自然月格式 YYYY-MM。
成本和费用统一进入 cost_expense再通过 category 区分类型。毛利和净利由系统根据收入、成本/费用自动计算。
### 5.8 AppScope 系统范围
- project_name科普慰心斋
- user_modesingle_user
- project_modesingle_project
- multi_user_enabledfalse
- multi_project_enabledfalse
---
## 6. 页面与交互规范
### 6.1 通用能力
- 所有列表支持新增。
- 所有详情支持编辑和保存。
- 所有日期字段使用日期选择器。
- 所有状态字段使用下拉选择。
- 跟进记录统一使用多条时间线记录。
- 保存后列表与详情数据同步更新。
### 6.2 抽屉交互
运营 Tab 和产品 Tab 使用右侧抽屉。
- 点击表格行或版本卡片打开抽屉。
- 抽屉展示完整详情。
- 抽屉内提供编辑和保存。
- 关闭抽屉时,如存在未保存修改,需要提示用户确认。
### 6.3 文件上传
业务方案 Tab 和运营 Tab 支持文件上传与在线预览。
- 上传文件必须归属于某个方案版本和某个要素。
- 上传后展示文件名、大小、上传时间。
- 文件必须支持下载。
- 文件必须支持在线预览。PDF 和图片优先使用浏览器内预览Word、Excel、PPT 可以先提供基础预览能力或转换预览方案,若首版无法直接预览,需要在界面中明确提示并保留下载入口。
- 运营文件必须先归属到明确项目版本,再按该版本要求补充对应文件。
---
## 7. 技术栈与实现约束
### 7.1 前端技术栈
OPC 工作台前端采用:
- Jinja2负责服务端页面模板渲染。
- Tailwind CSS CDN负责样式系统、布局、间距、颜色和响应式规则不使用 Vite 构建链。
- 原生 JavaScript负责 Tab 切换、抽屉详情、表单提交、文件上传、数据刷新。
- Lucide 浏览器版:作为图标库,负责导航、操作按钮、文件预览/下载等线性图标。
- Chart.js CDN负责财务趋势图包括收入、毛利、成本/费用、净利的月度曲线。
### 7.2 后端技术栈
OPC 工作台后端采用:
- Flask负责页面渲染、REST API、文件上传、文件访问、在线预览入口和静态资源服务。
- sqlite3负责 SQLite 数据读写,首版不引入 ORM。
- Jinja2随 Flask 用于模板渲染。
- Flask development server负责本地单用户运行。
### 7.3 数据库与存储
首版数据库采用 SQLite。
- 主数据库文件建议为 `data/opc.sqlite`
- 原财务 manager 的 SQLite 数据需要迁移或合并到 OPC 主数据库,不再作为独立系统运行。
- SQLite 中存储销售、跟进时间线、业务方案版本、运营项目、运营/交付文件元数据、产品版本、财务记录等结构化数据。
- 上传文件本体存储在本地文件系统,不直接写入 SQLite。
- 上传文件目录建议为 `data/uploads/`
- SQLite 仅保存文件元数据,包括文件名、类型、大小、所属模块、所属项目/版本、文件路径、上传时间。
- 后端需要提供文件下载和在线预览访问 URL。
### 7.4 财务 manager 合并策略
原财务 manager 系统位于 `/Users/mac/天机阁/地阁/慰心斋/5、财务管理/mananger`
合并要求:
- 不保留原财务 manager 作为独立入口。
- 复用或迁移原财务数据结构中的客户、回款、成本、文件索引等有价值数据。
- 将原财务 manager 的财务能力合并到 OPC 的财务 Tab。
- 财务数据最终统一进入 OPC 主数据库。
- 如存在字段差异,以 OPC v0.9 的 FinanceRecord 和财务口径为准。
### 7.5 API 设计原则
- API 采用 REST 风格。
- 所有业务对象提供基础 CRUD 接口。
- 文件上传接口必须接收模块、项目/版本、文件分类等归属信息。
- 财务汇总接口由后端根据明细计算收入、毛利、成本/费用、净利月度数据。
- 首页指标接口由后端聚合销售、运营、产品、财务数据生成。
- 首版不做登录、鉴权、多用户权限和租户隔离。
### 7.6 技术栈使用原则
- 不使用 Ant Design 作为首版组件库。
- 不新增重型前端框架或多套 UI 组件体系,避免视觉和交互风格分裂。
- Tailwind CSS CDN 需要承接设计系统中的颜色、间距、字号、圆角、阴影和状态表达。
- 不使用 React、Vite、Rollup、shadcn/ui 或 Recharts 作为首版运行依赖。
- Chart.js 图表需要与财务明细数据同源,避免图表和表格口径不一致。
- 后端沿用 Flask 技术路线,但不保留原财务 manager 独立系统;统一实现为 OPC Flask 应用。
- 数据库首版不引入 PostgreSQL、MySQL 等独立数据库服务。
---
## 8. 设计质量与 UI 要求
### 8.1 设计目标
OPC 工作台应采用企业级经营管理后台风格,重点是清晰、稳定、可扫描、适合会议复盘。不做营销页风格,不使用过度装饰、强动效或大面积渐变。
### 8.2 推荐视觉方向
- 主色建议采用稳重蓝色系,辅以红/橙/绿表达风险、待处理、完成等状态。
- 布局以白底、浅灰分区、细边框、紧凑表格为主。
- 首屏应直接进入工作台,不设置营销型 landing page。
- 销售、运营、财务等高频经营信息优先保证信息密度和可读性。
- 卡片只用于业务方案版本和产品版本,不把所有区块都做成大卡片。
- 抽屉用于承载详情编辑,避免跳转打断上下文。
### 8.3 组件风格要求
- 销售管理:表格 + 状态标签 + 详情编辑。
- 业务方案:版本卡片 + 文件分组上传。
- 运营:项目表格 + 项目类型筛选 + 抽屉详情。
- 产品:版本卡片 + 抽屉详情。
- 财务:月度趋势折线图 + 明细表。
### 8.4 设计辅助工具分工
首版开发应同时使用 `ui-ux-pro-max-skill``frontend-design`,二者分工如下:
- `ui-ux-pro-max-skill`负责设计系统生成、dashboard 风格参考、UI 审计框架。
- `frontend-design`:负责前端页面落地质量,包括布局、信息层级、组件组合、表格密度、抽屉体验、状态标签、图表与明细联动、响应式细节。
- Flask + Jinja2 + Tailwind CSS CDN + Lucide + Chart.js + 原生 JavaScript负责实际工程实现。
### 8.5 ui-ux-pro-max-skill 使用要求
开发前应使用 `ui-ux-pro-max-skill` 辅助生成或校准设计系统,用于提升 UI/UX 质量。
使用目标:
- 生成 OPC 工作台的设计系统建议,包括色彩、字体、间距、表格密度、状态标签和图表风格。
- 针对 sales dashboard、operations dashboard、financial dashboard、executive dashboard 等场景获取布局参考。
- 对最终页面进行 UI 审计,检查信息层级、对比度、间距一致性、状态表达、图表可读性和表格扫描效率。
使用边界:
- `ui-ux-pro-max-skill` 作为设计系统和 UI 审计辅助,不作为组件库。
- 实际开发必须使用 Flask + Jinja2 + Tailwind CSS CDN + Lucide + Chart.js + 原生 JavaScript + SQLite 落地。
- 不允许为了追求视觉效果牺牲经营信息的清晰度。
建议查询方向:
- enterprise operations dashboard
- sales pipeline dashboard
- project operations dashboard
- financial dashboard
- executive dashboard
交付要求:
- 开发前输出一份 OPC 工作台设计规范摘要。
- 首版开发完成后,基于 `ui-ux-pro-max-skill` 的审计思路输出一份 UI 自检结果。
### 8.6 frontend-design 使用要求
开发前和开发过程中应使用 `frontend-design` 辅助提高页面实现质量。
使用目标:
-`ui-ux-pro-max-skill` 产出的设计系统要求落到具体页面结构和组件组合。
- 校准首页、销售表格、业务方案卡片、运营抽屉、产品版本卡片、财务图表的布局和信息层级。
- 检查表格是否紧凑可扫描,抽屉是否适合编辑详情,卡片是否避免过度嵌套,图表和明细是否容易核对。
- 检查 Tailwind class、Lucide 图标、Chart.js 图表和原生交互是否保持统一视觉语言。
使用边界:
- `frontend-design` 作为页面设计和前端实现质量辅助,不替代 PRD不改变已确认的业务逻辑。
- 不为了视觉效果增加不必要的动画、营销页结构或复杂交互。
-`frontend-design` 建议与业务可读性冲突,以业务可读性和会议复盘效率优先。
交付要求:
- 开发前输出一份页面级 UI 落地清单。
- 首版开发完成后,基于 `frontend-design` 的检查思路完成页面级自检。
---
## 9. 首页指标要求
v0.9 必须加入全局首页概览,建议展示:
- P0 客户数
- 已签约执行项目数
- 有风险项目数
- 本月收入
- 本月净利
- 即将上线产品版本数
首页作为默认进入页,点击指标可跳转到对应 Tab。
---
## 10. 非功能需求
- 数据应持久化保存,刷新页面不丢失。
- 财务数据计算结果应与明细一致。
- 文件上传后应可追溯到对应业务方案版本。
- 运营文件上传后应可追溯到对应项目版本。
- 页面应支持桌面端优先使用。
- 表格内容应清晰紧凑,适合会议和经营复盘场景。
- 系统应保留后续复用到其他业务单元的扩展能力。
- 首版开发前应完成设计规范摘要,开发后应完成 UI 自检。
- 首版开发前应完成页面级 UI 落地清单,开发后应完成页面级自检。
- 前端实现必须符合 Jinja2 + Tailwind CSS CDN + Lucide + Chart.js + 原生 JavaScript 技术栈约束。
- 后端实现必须符合 Flask + sqlite3 + SQLite 技术栈约束。
- 上传文件必须采用本地文件系统存储SQLite 存储文件元数据。
- 首版为单用户、单项目版本,项目名称为科普(慰心斋)。
---
## 11. 验收标准
### 首页
- 默认进入系统后展示全局首页。
- 首页展示 P0 客户数、跟进中销售机会数、已签约执行项目数、有风险执行项目数、本月收入、本月净利、即将上线产品版本数。
- 首页展示风险提醒和近期动态。
- 点击首页指标可以跳转到对应 Tab。
### 销售管理
- 可以新增销售客户。
- 可以在表格中看到目标客户、优先级、状态、最新跟进记录。
- 点击行后可以查看和编辑详情。
- 销售跟进记录以多条时间线形式展示和维护。
### 业务方案
- 可以新增方案版本卡片。
- 每个卡片包含方案、成本、SOP、财务流程 4 个文件要素。
- 每个要素可以上传多个文件。
- 上传后文件能够在线预览和下载。
### 运营
- 可以新增运营项目。
- 可以将运营项目分为业务机会项目和已签约执行项目。
- 可以在表格中看到项目名称、项目类型、项目状态、当前阶段、交付文件、跟进记录。
- 点击项目后可以用抽屉查看和编辑详情。
- 业务机会项目的抽屉重点展示和编辑签约推进信息,包括客户需求、预计签约金额、预计签约时间、签约概率、下一步动作和跟进记录。
- 已签约执行项目的抽屉重点展示和编辑 SOP 执行信息,包括 SOP 文件、当前 SOP 阶段、执行进度、当前交付物、风险和跟进记录。
- 可以在抽屉中上传、在线预览、下载、删除项目相关运营/交付文件。
- 运营文件按项目版本补充,不建立未归类文件池。
- 可以按项目版本索引或导入 `/Users/mac/天机阁/地阁/慰心斋/3、运营方案` 中已有文件。
### 产品
- 可以新增产品版本卡片。
- 卡片展示版本目标、核心功能清单、上线日期、跟进记录。
- 点击卡片后可以用抽屉查看和编辑版本详情。
### 财务
- 可以录入收入、成本/费用明细。
- 系统按月份展示收入、毛利、成本/费用、净利曲线。
- 系统可以展示对应明细。
- 月份按自然月统计,格式为 YYYY-MM。
- 成本和费用统一作为成本/费用录入,再通过类型区分。
- 净利 = 收入 - 成本/费用。
### UI/UX
- 已使用 `ui-ux-pro-max-skill` 生成或校准 OPC 工作台设计规范摘要。
- 已使用 `frontend-design` 生成页面级 UI 落地清单。
- 前端页面基于 Jinja2 + Tailwind CSS CDN + 原生 JavaScript 实现。
- 图标库基于 Lucide 浏览器版实现。
- 财务趋势图基于 Chart.js 实现。
- 页面符合企业级经营管理后台风格,信息清晰、表格紧凑、状态可扫描。
- 销售、运营、产品详情通过抽屉承载,避免频繁跳转。
- 财务图表与明细表可对应核对。
- 首版完成后已基于 `ui-ux-pro-max-skill``frontend-design` 进行 UI 自检,并记录主要问题和修正结果。
### 后端与数据
- 后端基于 Flask 实现页面渲染、REST API 和文件服务。
- 数据读写基于 sqlite3 实现。
- 数据持久化使用 SQLite主数据库为 OPC 主库。
- 原财务 manager 不再作为独立系统运行,其能力合并进 OPC 财务 Tab。
- 文件本体存储在本地 uploads 目录SQLite 存储文件元数据。
- 后端提供文件上传、下载和在线预览访问入口。
- 首页指标和财务月度曲线由后端基于明细数据计算生成。
---
## 12. 已确认决策
1. v0.9 必须加入全局首页概览。
2. 销售管理中的跟进记录使用多条时间线记录。
3. 业务方案和运营交付文件需要支持在线预览和下载。
4. 运营文件不是先进入文件池再绑定项目,而是先确定项目版本,再按版本要求补充文件。
5. 财务图表按自然月统计,月份格式为 YYYY-MM。
6. 财务明细中的成本和费用统一为成本/费用,再按类型分类。
7. 首版先做单用户、单项目版本。
8. 单项目名称为科普(慰心斋),作为独立版本落地。
9. 后续再考虑多项目、多用户、权限体系。
10. 开发前同时使用 `ui-ux-pro-max-skill``frontend-design` 提升 UI/UX 质量。
11. 后端采用 Flask + sqlite3数据库采用 SQLite。
12. 文件本体存本地文件系统,数据库只存文件元数据。
13. 原财务 manager 合并进 OPC不再独立运行。
14. 前端采用 Jinja2 + Tailwind CSS CDN + Lucide + Chart.js + 原生 JavaScript不使用 Vite 构建链。

View File

@@ -0,0 +1,844 @@
# OPC 管理系统 PRD
版本v1.0
日期2026-06-01
阶段:实际落地版(匹配 OPC-Manager v1.0.2 代码实现)
定位:以科普(慰心斋)为单项目独立版本,打造 OPC 工作台,从全局首页、销售、业务、运营、产品、财务 6 个视角,直观、清晰、全面地管理业务全貌。
> 历史版本参见 `OPC管理系统PRD-V0.9.md`,修改日志见 `CHANGELOG.md`。
---
## 1. 背景与目标
### 1.1 需求原文
以慰心斋为例,打造一个 OPC 工作台,让我能够直观地看到他销售、业务、运营、产品、财务的情况。另外,之前他为自己单独打造了一个财务体系,把这个财务 manager 系统直接合并进工作台里面,不再作为一个独立的系统。
### 1.2 背景
当前业务信息分散在销售表、方案文件、运营记录、产品路线图、财务台账等多个位置,难以一眼判断业务进展、交付风险、产品节奏和财务结果。
OPC 管理系统用于建立一个统一工作台,让负责人可以通过全局首页和 5 个业务 Tab 完成日常管理:
1. 全局首页:集中展示科普(慰心斋)的经营关键指标和风险提醒。
2. 销售管理:看清目标客户、优先级、状态和多条时间线跟进记录。
3. 业务方案:按版本管理售前/商务阶段的方案、成本、SOP、财务流程等文件。
4. 运营管理:承接交付的实际管理形态,将项目分为业务机会项目和已签约执行项目,分别看清签约推进和 SOP 执行过程。
5. 产品管理:按版本管理产品目标、核心功能、上线日期和版本跟进。
6. 财务管理:用曲线图和明细表查看收入、毛利、成本/费用、净利。
本系统将承接并合并原有独立财务 manager 能力,财务不再作为单独系统存在,而是作为 OPC 管理系统中的一个 Tab。
### 1.3 样板范围
v1.0 以科普(慰心斋)作为首个单项目独立版本,优先接入和整理慰心斋已有资料:
- 销售资料:`/Users/mac/天机阁/地阁/慰心斋/1、销售管理`
- 业务方案:`/Users/mac/天机阁/地阁/慰心斋/2、业务方案`
- 运营资料:`/Users/mac/天机阁/地阁/慰心斋/3、运营方案`
- 产品资料:`/Users/mac/天机阁/地阁/慰心斋/4、产品方案`
- 财务系统:`/Users/mac/天机阁/地阁/慰心斋/5、财务管理/mananger`
首版只做单项目、单用户版本,不做多项目、多用户、权限体系。系统形态应保留后续复制到其他业务单元或升级为多项目/多用户的扩展能力,但首版数据、页面样例和迁移逻辑以科普(慰心斋)为准。
---
## 2. 用户与使用场景
### 2.1 核心用户
- 业务负责人:查看全局进展,识别销售、运营、产品、财务风险。
- 销售负责人:维护目标客户和跟进记录。
- 项目/运营负责人:维护业务机会推进、已签约项目 SOP 执行、交付文件和项目进展。
- 产品负责人:维护版本规划、上线日期和功能清单。
- 财务负责人:维护收入、成本/费用、毛利、净利明细。
### 2.2 高频场景
- 每周例会前,先通过全局首页查看销售 pipeline、项目运营状态、产品版本进度、财务结果和风险提醒。
- 新增客户后,录入客户优先级、当前状态和跟进记录。
- 一个业务方案形成新版本后上传方案、成本、SOP、财务流程文件。
- 项目处于机会阶段时,在运营 Tab 中跟进签约推进;项目签约后,在运营 Tab 中按 SOP 跟进执行过程和交付文件。
- 产品版本推进时,维护版本目标、核心功能、上线日期和跟进记录。
- 财务复盘时,查看收入、毛利、成本/费用、净利月度趋势和明细。
---
## 3. 信息架构
系统顶部为全局标题和基础操作区,主体为 6 个一级 Tab
1. 首页
2. 销售管理
3. 业务方案
4. 运营
5. 产品
6. 财务
所有 Tab 均支持基础的新增、查看、编辑能力。删除能力作为后台管理能力保留,默认不突出展示,避免误删关键业务数据。
---
## 4. 功能需求
### 4.1 首页 Tab
首页是科普(慰心斋)单项目的经营总览页,用于在进入具体 Tab 前先看到整体情况。
#### 页面形式
- 首页由关键指标卡、财务趋势图、风险提醒和近期动态组成。
- 首页展示的是销售、业务、运营、产品、财务五个模块的聚合信息,不承载复杂编辑。
#### 关键指标7 项4 列网格自动换行)
- P0 客户数
- 跟进中销售机会数
- 已签约执行项目数
- 有风险项目数
- 本月收入
- 本月净利
- 即将上线产品版本数
#### 风险提醒
- 预计签约时间临近但状态未推进的业务机会项目。
- 已签约执行项目中标记为有风险或存在阻塞的项目。
- 产品上线日期临近但状态未进入测试/已上线的版本。
- 当月净利为负或成本/费用异常升高的月份。
#### 近期动态
- 最近新增的销售跟进记录。
- 最近更新的运营项目跟进记录。
- 最近上传或更新的业务方案/运营交付文件。
- 最近新增的财务明细。
#### 交互要求
- 首页指标点击后可跳转到对应 Tab 并带入筛选条件。
- 首页只做查看和跳转,不在首页直接编辑业务数据。
- 财务趋势图容器固定高度 140px配合 Chart.js `maintainAspectRatio: false`
---
### 4.2 销售管理 Tab
#### 页面形式
- 内容按表格形式展示。
- 表格字段:
- 目标客户
- 优先级
- 状态
- 最新跟进记录
#### 交互要求
- 用户可以新增销售线索/目标客户。
- 用户点击表格中的某一行后以抽屉形式打开详情720px 宽)。
- 抽屉内详情字段:
- 目标客户
- 优先级(下拉选择)
- 状态(下拉选择)
- 字段失焦自动保存,不需要保存按钮。
- 抽屉右上角关闭按钮关闭。
- 跟进记录必须是多条时间线记录,不使用单一文本字段。
- 用户可以在详情中通过 Squire 富文本编辑器新增评论,支持删除已有评论。
#### 跟进时间线字段
- 跟进时间
- 跟进人
- 跟进方式
- 跟进内容
- 下一步动作
- 下次跟进时间
#### 推荐状态字段
- 待跟进
- 跟进中
- 方案中
- 商务谈判
- 已签约
- 暂缓
- 已丢单
#### 推荐优先级字段
- P0
- P1
- P2
- P3
---
### 4.3 业务方案 Tab
> **v1.0 变更**:从 v0.9 的"一个版本一个卡片"改为表格布局,保持与销售、运营一致的交互模式。
#### 页面形式
- 按表格形式展示。
- 表格字段:
- 客户/项目名称
- 版本号
- 状态
- 文件数
#### 每个版本包含 4 个文件要素
1. 方案
2. 成本
3. SOP
4. 财务流程
#### 文件规则
- 一个要素对应一个文件分组。
- 每个文件分组允许上传多个文件。
- 文件类型不限于Word、Excel、PPT、PDF、图片、压缩包。
#### 交互要求
- 用户可以新增一个业务方案版本。
- 用户点击表格行后以抽屉形式展开720px 宽)。
- 抽屉内展示两大部分:
1. **属性区**:客户/项目名称、版本号、版本说明、创建日期、状态(均可编辑,失焦自动保存)
2. **方案文件区**:按方案/成本/SOP/财务流程 4 个分组展示,每组支持上传、预览、下载、删除文件
- 用户可以查看每个要素下已上传的文件列表。
- 用户可以替换、追加或删除文件。
- 用户可以在线预览已上传文件。PDF 和图片优先支持浏览器内预览Word、Excel、PPT 提供下载。
- 用户可编辑版本基础信息:客户/项目名称、版本号、版本说明、创建日期、当前状态。
- 抽屉内支持 Squire 富文本评论,评论支持删除。
#### 推荐版本状态
- 草稿
- 内部评审
- 已提交客户
- 客户反馈中
- 已确认
- 已归档
---
### 4.4 运营 Tab
运营是项目从机会推进到签约交付的实际承载形式。系统不单独设置"交付 Tab",交付过程、交付文件、项目执行状态统一归入运营 Tab 管理。
运营项目分为两类:
1. 业务机会项目:尚未签约,以推动签约为核心目标,重点展示客户意向、签约推进状态、下一步动作和跟进记录。
2. 已签约执行项目:已经签约,以按 SOP 交付为核心目标,重点展示 SOP 阶段、执行进度、交付物、风险和跟进记录。
首批运营文件来源:`/Users/mac/天机阁/地阁/慰心斋/3、运营方案`。当前已存在的文件按版本要求补充到对应运营项目,不建立"未归类文件池"。
#### 页面形式
- 按表格形式展示。
- 表格顶部提供项目类型筛选按钮(三选一):
- 全部项目
- 业务机会项目
- 已签约执行项目
- 表格字段:
- 项目名称(含版本号)
- 项目类型
- 项目状态
- 当前阶段
- 交付文件数
- 最新跟进
#### 交互要求
- 用户可以新增运营项目。
- 新增项目时必须选择项目类型:业务机会项目或已签约执行项目。
- 用户点击表格行后以抽屉形式展开720px 宽)。
- 抽屉内展示所有字段(失焦自动保存):
- 项目名称
- 项目版本
- 项目状态
- 当前阶段
- 目标客户
- 客户需求
- 预计签约金额
- 预计签约时间
- 签约概率
- SOP 阶段
- 执行进度
- 当前交付物
- 风险与阻塞
- 下一步动作
- 抽屉内支持 Squire 富文本评论,评论支持删除。
- 运营文件必须先归属到明确项目版本,再根据该版本要求补充文件。
- 已放入运营管理文件夹的文件,需要作为对应项目版本的初始文件导入或索引到运营 Tab。
#### 业务机会项目 vs 已签约执行项目
两种项目类型共用同一个抽屉和数据模型,根据 project_type 字段区分:
- 当 project_type = opportunity 时,优先关注:目标客户、客户需求、预计签约金额、预计签约时间、签约概率、下一步动作。
- 当 project_type = execution 时优先关注SOP 阶段、执行进度、当前交付物、风险。
#### 推荐项目类型
- 业务机会项目opportunity
- 已签约执行项目execution
#### 业务机会项目推荐状态
- 线索发现
- 需求沟通
- 方案准备
- 方案已提交
- 商务谈判
- 待签约
- 已签约
- 暂缓
- 已丢单
#### 已签约执行项目推荐状态
- 未启动
- SOP 执行中
- 有风险
- 待客户确认
- 已完成
- 已归档
#### SOP 阶段建议
- 项目启动
- 需求确认
- 方案确认
- 内容生产
- 审核确认
- 渠道分发
- 数据/效果回收
- 复盘验收
---
### 4.5 产品 Tab
> **v1.0 变更**:从 v0.9 的"一个版本一个卡片"改为表格布局,保持与销售、业务方案、运营一致的交互模式。
#### 页面形式
- 按表格形式展示。
- 表格字段:
- 产品名称
- 版本号
- 版本目标
- 核心功能
- 上线日期
- 状态
#### 交互要求
- 用户可以新增产品版本。
- 用户点击表格行后以抽屉形式展开720px 宽)。
- 抽屉内展示版本详情(失焦自动保存):
- 产品名称
- 版本号
- 版本目标
- 核心功能清单
- 上线日期
- 当前状态
- 备注
- 抽屉内支持 Squire 富文本评论,评论支持删除。
#### 推荐版本状态
- 规划中
- 设计中
- 开发中
- 测试中
- 已上线
- 已延期
- 已取消
---
### 4.6 财务 Tab
#### 页面结构
财务 Tab 分为趋势图区域和明细区域。
#### 趋势图
需要展示 4 条月度曲线:
1. 收入按月份的曲线图
2. 毛利按月份的曲线图
3. 成本/费用按月份的曲线图
4. 净利按月份的曲线图
#### 明细表
每个指标需要对应明细:
1. 收入明细
2. 毛利明细
3. 成本/费用明细
4. 净利明细
#### 推荐财务口径
- 收入:当月确认收入金额。
- 成本/费用:统一录入为成本/费用,再通过类型区分直接成本、期间费用、运营费用、管理费用等。
- 毛利:收入 - 成本。
- 净利:收入 - 成本/费用。
- 月份统计按自然月,月份格式为 YYYY-MM。
#### 明细字段
收入明细:
- 月份
- 客户/项目
- 收入类型
- 金额
- 确认日期
- 备注
成本/费用明细:
- 月份
- 客户/项目
- 类型
- 金额
- 发生日期
- 备注
净利明细:
- 月份
- 收入合计
- 成本/费用合计
- 毛利
- 净利
---
## 5. 数据对象
### 5.1 SalesLead 销售线索
- id
- target_customer
- priority
- status
- latest_follow_up_record
- created_at
- updated_at
### 5.2 FollowUpRecord 跟进时间线记录
- id
- target_typesales / operation / product
- target_id
- followed_at
- follower
- follow_up_method
- contentHTML 格式Squire 生成)
- next_action
- next_follow_up_at
- created_at
- updated_at
### 5.3 BusinessProposal 业务方案版本
- id
- customer_or_project_name
- version
- description
- status
- created_date
- created_at
- updated_at
文件通过 FileAsset 关联module = "proposal"),按 file_category 分组:
- 方案
- 成本
- SOP
- 财务流程
### 5.4 OperationProject 运营项目
- id
- project_name
- project_version
- project_typeopportunity / execution
- project_status
- current_stage
- owner
- start_date
- end_date
- target_customer
- customer_need
- expected_contract_amount
- expected_sign_date
- sign_probability
- next_action
- related_business_proposal_id
- sop_file_id
- sop_stage
- execution_progress
- current_deliverable
- risks
- notes
- created_at
- updated_at
### 5.5 ProductVersion 产品版本
- id
- product_name
- version
- version_goal
- feature_list
- launch_date
- status
- notes
- created_at
- updated_at
### 5.6 FinanceRecord 财务记录
- id
- monthYYYY-MM 格式)
- project_name
- record_typerevenue / cost_expense
- category
- amount
- occurred_date
- notes
- created_at
- updated_at
### 5.7 FileAsset 文件资产
- id
- moduleproposal / operation
- owner_id
- owner_version
- file_category
- file_name
- file_type
- file_size
- file_path
- is_external1=原始索引文件0=本地上传文件)
- notes
- created_at
- updated_at
### 5.8 AppScope 系统范围
- project_name科普慰心斋
- user_modesingle_user
- project_modesingle_project
- multi_user_enabledfalse
- multi_project_enabledfalse
---
## 6. 页面与交互规范
### 6.1 通用能力
- 所有列表支持新增(内联表单置顶)。
- 所有详情通过抽屉展示和编辑。
- 所有日期字段使用文本输入。
- 所有状态字段使用下拉选择。
- 跟进记录统一使用多条时间线记录Squire 富文本编辑器支持 HTML 格式。
- 字段失焦自动保存,保存状态有视觉反馈(保存中…/已保存/保存失败)。
- 点击表格行打开抽屉,不需要单独的"详情"按钮。
- 保存后列表与详情数据同步更新(自动触发全量 re-render
### 6.2 抽屉交互
所有 Tab 的详情均使用右侧抽屉。
- 抽屉固定宽度 720pxPlane 风格紧凑布局。
- 字段使用 `drawer-value` 样式默认透明背景、hover 显示边框、focus 蓝色边框。
- 字段失焦自动保存,不需要保存按钮。
- 抽屉右上角关闭按钮关闭。
- 已删除"点击抽屉外区域自动关闭抽屉"的交互。
- 抽屉内支持评论区Squire 富文本编辑器)。
- 评论提交后抽屉保持打开,触发数据刷新。
### 6.3 文件上传
业务方案 Tab 和运营 Tab 支持文件上传与在线预览。
- 上传文件必须归属于某个方案版本和某个要素。
- 上传后展示文件名、类型、大小、上传时间。
- 文件支持下载Content-Disposition: attachment
- 文件支持在线预览Content-Disposition: inline。PDF 和图片优先使用浏览器内预览Word、Excel、PPT 提供下载。
- 运营文件必须先归属到明确项目版本,再按该版本要求补充对应文件。
- 外部索引文件(慰心斋原始文件)通过 is_external=1 标记,不重复存储。
### 6.4 评论区
- 使用 Squire RTEFastmail作为富文本编辑器。
- 支持格式:加粗、斜体、下划线、删除线、无序列表、有序列表、引用、撤销、重做。
- 工具栏按钮使用 `onmousedown` 事件,防止点击按钮时编辑器失焦。
- 评论 HTML 用 `encodeURIComponent` 存入 `data-html` 属性,渲染时 `decodeURIComponent` 解析到 `.rich-content` 容器。
- 评论内容区通过 `.rich-content ul/ol { list-style: revert; }` 覆盖 Tailwind CSS reset确保列表编号/圆点正常显示。
- 所有评论记录均有删除图标hover 时显示),点击后弹窗确认删除。
---
## 7. 技术栈与实现约束
### 7.1 前端技术栈
OPC 工作台前端采用:
- Jinja2服务端渲染单一页面模板`templates/index.html`)。
- Tailwind CSS CDN样式系统、布局、间距、颜色和响应式规则不使用 Vite 构建链。
- 原生 JavaScript`static/app.js`,约 465 行Tab 切换、抽屉详情、表单提交、文件上传、数据刷新、自动保存。
- 自定义 CSS`static/styles.css`,约 492 行Drawer 样式、Squire 编辑器样式、表格行 hover、状态 badge、评论区、活动时间线。
- Chart.js CDN 4.4.8chart.js财务趋势折线图首页 + 财务 Tab 各一个),配置 `maintainAspectRatio: false` + 固定容器高度。
- Lucideunpkg CDN线性图标库用于导航、操作按钮、文件预览/下载、属性标签等,尺寸 16px。
- Squire RTE CDN v1`build/squire-raw.js`):富文本评论编辑器,支持 HTML 格式。
### 7.2 后端技术栈
OPC 工作台后端采用:
- Flask 3.1.3页面渲染Jinja2 模板、REST API、文件上传、文件访问、在线预览入口和静态资源服务。
- sqlite3Python 标准库,无 ORMSQLite 数据读写。
- Flask development server本地单用户运行端口 5177debug 模式。
### 7.3 数据库与存储
首版数据库采用 SQLite。
- 主数据库文件:`data/opc.sqlite`(已在 .gitignore 中排除)。
- 原财务 manager 的 SQLite 数据需要迁移或合并到 OPC 主数据库,不再作为独立系统运行。
- SQLite 中存储销售、跟进时间线、业务方案版本、运营项目、文件元数据、产品版本、财务记录等结构化数据。
- 上传文件本体存储在本地文件系统(`data/uploads/`),不直接写入 SQLite。
- 外部引用文件(慰心斋原始文件)通过 file_assets 表的 is_external 字段标记,路径指向原始文件位置。
- SQLite 仅保存文件元数据,包括文件名、类型、大小、所属模块、所属项目/版本、文件路径、上传时间。
- 后端提供文件下载和在线预览访问 URL`/api/files/<id>/content?inline=true|false`)。
### 7.4 财务 manager 合并策略
原财务 manager 系统位于 `/Users/mac/天机阁/地阁/慰心斋/5、财务管理/mananger`
合并要求:
- 不保留原财务 manager 作为独立入口。
- 复用或迁移原财务数据结构中的客户、回款、成本、文件索引等有价值数据。
- 将原财务 manager 的财务能力合并到 OPC 的财务 Tab。
- 财务数据最终统一进入 OPC 主数据库。
- 如存在字段差异,以 OPC v1.0 的 FinanceRecord 和财务口径为准。
### 7.5 API 设计原则
- API 采用 REST 风格。
- `/api/bootstrap`:一次性加载所有模块数据 + 首页聚合指标 + 财务月度汇总。
- 所有业务对象提供基础 CRUD 接口(`/api/<resource>` + `/api/<resource>/<id>`)。
- 文件上传接口通过 FormData 接收 file + module + owner_id + owner_version + file_category。
- 财务汇总接口由后端根据明细计算收入、毛利、成本/费用、净利月度数据。
- 首页指标接口由后端聚合销售、运营、产品、财务数据生成。
- `/api/followups/<target_type>/<target_id>`:新增评论。
- `/api/followups/<id>`:删除评论。
- 首版不做登录、鉴权、多用户权限和租户隔离。
### 7.6 项目结构
```
OPC-Manager/
├── backend/
│ ├── flask_app.py # Flask 主应用(~480 行)
│ └── requirements.txt
├── templates/
│ └── index.html # Jinja2 页面模板
├── static/
│ ├── app.js # 原生 JS 页面交互(~465 行)
│ └── styles.css # 自定义样式(~492 行)
├── data/
│ ├── opc.sqlite # SQLite 数据库gitignore
│ └── uploads/ # 上传文件目录gitignore
├── docs/
│ ├── design-system-summary.md
│ └── frontend-design-checklist.md
├── scripts/
│ └── deploy.sh # 标准部署脚本
├── VERSION_LOG.md # 版本发布日志
└── README.md
```
### 7.7 开发约束
- 不使用 Ant Design、React、Vite、shadcn/ui 等重型框架。
- 不新增多套 UI 组件体系。
- Tailwind CSS CDN 承接设计系统中的颜色、间距、字号、圆角、阴影和状态表达。
- 不使用 PostgreSQL、MySQL 等独立数据库服务。
- 不修改 AiMaMi 本地代理配置。
- 不使用 destructive git 命令。
- 不提交 `data/opc.sqlite` 到仓库。
- 修改代码后必须运行:`node --check static/app.js` + `curl /api/health`
- 服务启动前先清理 5177 端口占用。
---
## 8. 设计规范
### 8.1 设计目标
OPC 工作台采用企业级经营管理后台风格,重点是清晰、稳定、可扫描、适合会议复盘。不做营销页风格,不使用过度装饰、强动效或大面积渐变。
### 8.2 视觉规范
| 要素 | 规范 |
|------|------|
| 主色 | `#2563eb` (blue-600) / `#1d4ed8` (blue-700) |
| 背景 | `bg-slate-50` 浅灰 |
| 卡片/表格底色 | `white` + `border-slate-200` 细边框 |
| 风险状态 | 红 `#fef2f2` / `#b91c1c` |
| 待处理状态 | 琥珀 `#fffbeb` / `#b45309` |
| 完成状态 | 绿 `#ecfdf5` / `#047857` |
| 信息状态 | 蓝 `#eff6ff` / `#1d4ed8` |
| 归档/默认 | 灰 `#f1f5f9` / `#475569` |
| 圆角 | 表格 8px按钮 6pxbadge 全圆 |
| 表格行 hover | `#f1f5f9` 浅灰背景 |
| 字段编辑态 | 透明→hover 浅灰→focus 蓝边框 |
### 8.3 组件风格要求
- 销售管理:表格 + 状态标签 + 抽屉详情。
- 业务方案:表格 + 状态标签 + 文件分组(抽屉内 4 组文件上传/预览/下载)。
- 运营:表格 + 项目类型筛选 + 抽屉详情。
- 产品:表格 + 状态标签 + 抽屉详情。
- 财务:月度趋势折线图 + 明细表。
### 8.4 设计辅助工具(已使用)
v1.0 开发前使用了以下设计辅助工具:
- `ui-ux-pro-max-skill`:生成设计系统摘要(`docs/design-system-summary.md`),定义色彩、布局、组件规则。
- `frontend-design`:生成页面级 UI 落地清单(`docs/frontend-design-checklist.md`),定义各页面的检查要点和 Post-Development Audit 标准。
实际落地采用 Flask + Jinja2 + Tailwind CSS CDN + Lucide + Chart.js + 原生 JavaScript + SQLite 技术栈,设计规范作为指导但以代码实现为准。
---
## 9. 首页指标要求
v1.0 首页展示 7 项关键指标4 列网格自动换行):
- P0 客户数
- 跟进中销售机会数
- 已签约执行项目数
- 有风险项目数
- 本月收入
- 本月净利
- 即将上线产品版本数
首页作为默认进入页,点击指标可跳转到对应 Tab。
---
## 10. 非功能需求
- 数据应持久化保存,刷新页面不丢失。
- 财务数据计算结果应与明细一致。
- 文件上传后应可追溯到对应业务方案版本。
- 运营文件上传后应可追溯到对应项目版本。
- 页面应支持桌面端优先使用(最小宽度 1180px
- 表格内容应清晰紧凑,适合会议和经营复盘场景。
- 系统应保留后续复用到其他业务单元的扩展能力。
- 前端实现必须符合 Jinja2 + Tailwind CSS CDN + Lucide + Chart.js + Squire + 原生 JavaScript 技术栈约束。
- 后端实现必须符合 Flask + sqlite3 + SQLite 技术栈约束。
- 上传文件必须采用本地文件系统存储SQLite 存储文件元数据。
- 首版为单用户、单项目版本,项目名称为科普(慰心斋)。
---
## 11. 验收标准
### 首页
- 默认进入系统后展示全局首页。
- 首页展示 7 项关键指标4 列网格)。
- 首页展示财务趋势图、风险提醒和近期动态。
- 点击首页指标可以跳转到对应 Tab。
### 销售管理
- 可以新增销售客户。
- 可以在表格中看到目标客户、优先级、状态、最新跟进记录。
- 点击行后可以打开抽屉查看和编辑详情(失焦自动保存)。
- 销售跟进记录以多条时间线形式展示,支持 Squire 富文本评论和删除。
### 业务方案
- 可以新增方案版本。
- 点击行后打开抽屉,展示属性和 4 个文件分组(方案/成本/SOP/财务流程)。
- 每个分组可以上传多个文件。
- 上传后文件能够在线预览和下载。
- 抽屉内支持评论。
### 运营
- 可以新增运营项目。
- 可以将运营项目分为业务机会项目和已签约执行项目。
- 可以在表格中看到项目名称、类型、状态、当前阶段、文件数、最新跟进。
- 顶部提供项目类型筛选按钮(全部/业务机会/已签约执行)。
- 点击项目后可以用抽屉查看和编辑详情。
- 抽屉内支持评论。
### 产品
- 可以新增产品版本。
- 表格展示产品名称、版本号、版本目标、核心功能、上线日期、状态。
- 点击行后可以用抽屉查看和编辑版本详情。
- 抽屉内支持评论。
### 财务
- 可以录入收入、成本/费用明细。
- 系统按月份展示收入、毛利、成本/费用、净利曲线(首页 + 财务 Tab 各一个)。
- 系统可以展示对应明细。
- 月份按自然月统计,格式为 YYYY-MM。
- 成本和费用统一作为成本/费用录入,再通过类型区分。
- 净利 = 收入 - 成本/费用。
### UI/UX
- 前端页面基于 Jinja2 + Tailwind CSS CDN + 原生 JavaScript 实现。
- 图标库基于 Lucide 浏览器版实现。
- 富文本评论基于 Squire RTE 实现。
- 财务趋势图基于 Chart.js 实现。
- 页面符合企业级经营管理后台风格,信息清晰、表格紧凑、状态可扫描。
- 销售、运营、产品、业务方案详情通过 720px 抽屉承载。
- 字段失焦自动保存,有状态反馈。
- 财务图表与明细表可对应核对。
### 后端与数据
- 后端基于 Flask 实现页面渲染、REST API 和文件服务。
- 数据读写基于 sqlite3 实现。
- 数据持久化使用 SQLite主数据库为 OPC 主库。
- 原财务 manager 不再作为独立系统运行,其能力合并进 OPC 财务 Tab。
- 文件本体存储在本地 uploads 目录SQLite 存储文件元数据。
- 后端提供文件上传、下载和在线预览访问入口。
- 首页指标和财务月度曲线由后端基于明细数据计算生成。
---
## 12. 版本历史
| 版本 | 日期 | 阶段 | 说明 |
|------|------|------|------|
| v0.9 | 2026-05-29 | 需求核对稿 | 初始 PRD定义 6 Tab 功能架构、数据模型、技术选型 |
| v1.0 | 2026-06-01 | 实际落地版 | 匹配 OPC-Manager v1.0.2 代码实现,修正业务方案/产品从卡片改表格,补充 Squire 编辑器、抽屉交互等实际落地细节 |
详细修改日志见 `CHANGELOG.md`

View File

@@ -0,0 +1,338 @@
# OPC 管理系统 PRD
版本v2.0
日期2026-06-16
阶段:当前落地版(匹配 OPC-Manager v1.7.6 代码实现)
定位:以科普(慰心斋)为单项目独立版本,打造 OPC 工作台,从全局首页、重点项目、业务方案、产品研发、财务管理 5 个视角,直观、清晰、全面地管理业务全貌。
> 历史版本参见 `OPC管理系统PRD-V1.0.md`、`OPC管理系统PRD-V0.9.md`,修改日志见 `CHANGELOG.md`。
---
## 1. 背景与目标
### 1.1 需求原文
以慰心斋为例,打造一个 OPC 工作台,让我能够直观地看到他销售、业务、运营、产品、财务的情况。另外,之前他为自己单独打造了一个财务体系,把这个财务 manager 系统直接合并进工作台里面,不再作为一个独立的系统。
### 1.2 背景与 v2.0 核心变更
v1.0 的 6 Tab 架构(销售管理 + 业务方案 + 运营管理 + 产品研发 + 财务管理 + 首页)在实际使用中暴露出销售与运营割裂的问题——业务机会和签约执行项目本质上是同一项目的不同阶段,分两个 Tab 管理不自然。
v2.0 做了以下架构级调整:
1. **合并销售 + 运营为「重点项目」**:一个 Tab 管全部项目,项目表格 6 列(项目/说明/阶段/金额/负责人/进展)。
2. **引入项目任务体系**:每个项目内嵌 6 阶段任务清单(商务洽谈→系统上线→团队分工→项目交付→上线推广→结项验收),支持增删改。
3. **任务详情改为页内二级页面**:点击「查看」进入项目任务页,有返回按钮,不弹出独立窗口。
4. **任务编辑改为右侧抽屉**:新增/编辑任务从右侧滑入表单,不打断任务列表浏览。
5. **首页指标分层**:从 7 项扩展到 14 项,分为经营核心层和财务分层。
6. **产品增加平台分类**:真研平台/科普平台/关爱平台三种归属。
本系统将承接并合并原有独立财务 manager 能力,财务不再作为单独系统存在,而是作为 OPC 管理系统中的一个 Tab。
### 1.3 样板范围
v2.0 以科普(慰心斋)作为首个单项目独立版本,当前包含信达·科普无界、圆心科技科普文章等多个重点项目。
首版只做单项目、单用户版本,不做多项目、多用户、权限体系。
---
## 2. 信息架构
系统顶部为全局标题和基础操作区,主体为 5 个一级 Tab
1. **首页** — 经营总览14 项指标 + 财务趋势 + 风险提醒 + 近期动态)
2. **重点项目** — 项目列表 + 点击进入任务清单6 阶段任务管理)
3. **业务方案** — 按版本管理方案/成本/SOP/财务流程文件
4. **产品研发** — 按版本管理产品目标/功能/日期(含平台分类)
5. **财务管理** — 趋势图 + 明细表(收入/成本/毛利/净利)
---
## 3. 功能需求
### 3.1 首页 Tab
首页是科普(慰心斋)单项目的经营总览页。
#### 3.1.1 关键指标14 项,两组 4 列网格)
**第一组 — 经营核心层8 项):**
- P0 客户数
- 跟进中销售机会数
- 已签约执行项目数
- 有风险项目数
- 本月收入
- 本月净利
- 即将上线产品版本数
- 已签约未执行金额
**第二组 — 财务分层6 项):**
- 已签约合同总额
- 合同流程中金额
- 年度累计确收
- Q2 累计确收
- 年度累计毛利
- Q2 累计毛利
> 以上指标由后端在 `/api/bootstrap` 中聚合计算(年度=2026年1-6月Q2=2026年4-6月。金钱单位统一为万元。
#### 3.1.2 财务趋势图
- 首页展示收入/成本月度折线图Chart.js固定高度 140px
- 财务 Tab 独立展示 4 条曲线(收入/毛利/成本/净利)。
#### 3.1.3 风险提醒与近期动态
- 风险提醒:从运营项目提取 next_action 非空的项目,最多展示 5 条。
- 近期动态:最近 8 条跟进记录时间线。
---
### 3.2 重点项目 Tab
> **v2.0 核心模块**,合并 v1.0 的「销售管理」和「运营管理」两个 Tab。
#### 3.2.1 项目列表
| 列 | 说明 |
|----|------|
| 项目 | 项目名称 |
| 项目说明 | customer_need 或 notes 字段 |
| 当前阶段 | 6 阶段之一badge 样式) |
| 项目金额 | expected_contract_amount万元 |
| 负责人 | owner |
| 进展 | 「查看」按钮,点击进入任务清单页 |
- 右上角「新增项目」按钮,点击展开创建表单。
- 表单字段:项目名称、当前阶段(下拉)、项目金额、负责人。
- 点击表格行打开右侧抽屉720px展示项目详情和评论区。
- 抽屉内新增「删除」按钮(红色,点击 confirm 确认后删除)。
#### 3.2.2 项目抽屉(详情)
6 个字段(失焦自动保存):
| 字段 | 类型 | 说明 |
|------|------|------|
| 项目名称 | text | project_name |
| 当前阶段 | select | 6 阶段下拉change 自动保存 |
| 截止时间 | text | expected_sign_date |
| 金额 | text | expected_contract_amount |
| 负责人 | text | owner |
| 项目说明 | textarea | notes多行文本 |
- 评论区保留,使用 Squire 富文本编辑器。
#### 3.2.3 任务清单页(二级页面)
点击「查看」进入项目任务清单页,在重点项目 Tab 内渲染:
- 顶部:「← 返回项目列表」按钮(左)+ 项目名(中)+「新增任务」按钮(右)
- 主体:按 6 阶段分组的任务卡片,空阶段自动隐藏
- 任务行格式:`○ 任务名 任务说明 | 负责人 截止时间`
- 卡点以红色 `⚠` 标记显示
#### 3.2.4 6 阶段体系
| 阶段 | 含义 |
|------|------|
| 商务洽谈 | 合同签订、渠道对接、接口推流等前期商务工作 |
| 系统上线 | 系统开发、SOP 制定、脚本生产等平台搭建 |
| 团队分工 | 人员招聘、审核人员到位等团队组建 |
| 项目交付 | 供应商准入、拍摄制作等执行交付 |
| 上线推广 | 项目启动上线、渠道全面推广 |
| 结项验收 | 客户验收、尾款结算等收尾工作 |
#### 3.2.5 任务编辑抽屉
- 点击任务行或「新增任务」→ 右侧抽屉滑入fixed 定位,始终可见)
- 表单字段:任务名称、任务阶段(下拉)、负责人、截止时间、任务说明、卡点&备注
- 新增时标题「新增任务」,编辑时「编辑任务」
- 提交后抽屉关闭,列表刷新
#### 3.2.6 数据模型
**project_tasks 表:**
| 字段 | 说明 |
|------|------|
| id | 主键 |
| project_id | 关联 operation_projects.id |
| phase | 阶段(商务洽谈/系统上线/团队分工/项目交付/上线推广/结项验收) |
| milestone | 里程碑 |
| task | 任务名称 |
| owner | 负责人 |
| due_date | 截止时间 |
| blockers | 卡点&备注 |
| notes | 任务说明 |
| created_at / updated_at | 时间戳 |
---
### 3.3 业务方案 Tab
与 v1.0 保持一致:
- 表格:客户/项目名称、版本号、状态、文件数
- 抽屉:属性(可编辑)+ 4 组文件(方案/成本/SOP/财务流程)
- 文件支持上传、预览、下载、删除
- 评论区支持 Squire 富文本
---
### 3.4 产品研发 Tab
与 v1.0 保持一致,增加平台分类:
- 表格:产品名称、版本号、版本目标、核心功能、平台、上线日期、状态
- 抽屉:可编辑版本详情 + 评论区
- 平台字段:下拉选择(真研平台/科普平台/关爱平台)
---
### 3.5 财务管理 Tab
与 v1.0 保持一致:
- 趋势图:收入/毛利/成本/净利 4 条月度曲线Chart.js
- 明细表:支持录入/编辑收入、成本/费用明细
- 月份格式 YYYY-MM
- 净利 = 收入 - 成本/费用
---
## 4. 数据对象
### 4.1 SalesLead销售线索
仍保留数据库表和 API但前端不再有独立 Tab。通过重点项目页可间接查看。
### 4.2 OperationProject运营项目
- id, project_name, project_version
- project_typeopportunity / execution现主要用于区分链路前端不做筛选
- current_stage6 阶段之一)
- owner, expected_contract_amount, expected_sign_date
- customer_need, notes
- project_status保留兼容不作为主要显示字段
- created_at, updated_at
### 4.3 ProjectTask项目任务—— v2.0 新增
- id, project_id
- phase, milestone, task
- owner, due_date
- blockers, notes
- created_at, updated_at
### 4.4 其他数据对象
BusinessProposal、ProductVersion、FinanceRecord、FileAsset、FollowUpRecord 与 v1.0 一致,详见 `OPC管理系统PRD-V1.0.md` 第 5 章。
---
## 5. 页面与交互规范
### 5.1 通用能力
- 所有列表支持新增(内联表单或右上角按钮)。
- 项目详情通过 720px 右侧抽屉展示和编辑。
- 任务清单在 Tab 内作为二级页面渲染,不弹出独立窗口。
- 任务编辑通过右侧抽屉420pxfixed 定位)。
- 字段失焦自动保存input/textarea → blurselect → change
- 评论区使用 Squire 富文本,支持 HTML 格式。
- 点击表格行打开抽屉,不需要单独的「详情」按钮。
### 5.2 抽屉交互
- 项目抽屉宽度 720px任务抽屉宽度 420px。
- 字段默认透明背景、hover 显示边框、focus 蓝色边框。
- 字段失焦/选择自动保存,保存状态有视觉反馈。
- 抽屉右上角关闭按钮关闭,项目抽屉额外有「删除」按钮。
- 评论提交后抽屉保持打开。
### 5.3 任务清单页交互
- 进入:点击项目行的「查看」按钮 → state.projectView = projectId → 刷新 Tab 内容。
- 退出:点击「返回项目列表」→ state.projectView = null → 回到列表。
- 工具栏与下方卡片使用相同的 20px 左右内边距px-5
- 空阶段分组不渲染(直接跳过)。
- 点击任务行打开编辑抽屉;点击「新增任务」打开空白新增抽屉。
- Lucide 图标在每次渲染后调用 `createIcons()` 刷新。
### 5.4 评论区
与 v1.0 一致,详见 `OPC管理系统PRD-V1.0.md` 第 6.4 节。
---
## 6. 技术栈与实现约束
与 v1.0 一致,核心为:
- **前端**Jinja2 + Tailwind CSS CDN + Vanilla JS + Chart.js + Lucide + Squire
- **后端**Flask + sqlite3无 ORM+ SQLite
- **单服务**Flask dev server, 端口 5177
- **不提交** `data/opc.sqlite`
详见 `OPC管理系统PRD-V1.0.md` 第 7 章。
**v2.0 新增数据表**`project_tasks`
---
## 7. 首页指标要求
v2.0 首页展示 14 项关键指标(两组 4 列网格):
**经营核心层:**
- P0 客户数、跟进中销售机会数、已签约执行项目数、有风险项目数
- 本月收入、本月净利、即将上线产品版本数、已签约未执行金额
**财务分层:**
- 已签约合同总额、合同流程中金额
- 年度累计确收、Q2 累计确收
- 年度累计毛利、Q2 累计毛利
---
## 8. 验收标准
### 首页
- 展示 14 项指标(两组 4 列网格)。
- 展示财务趋势图、风险提醒和近期动态。
- 点击指标可跳转到对应 Tab。
### 重点项目
- 项目列表 6 列展示(项目/说明/阶段/金额/负责人/进展)。
- 右上角「新增项目」展开表单。
- 点击行打开抽屉6 字段 + 评论 + 删除按钮)。
- 点击「查看」进入二级任务清单页。
- 任务清单按 6 阶段分组,空阶段隐藏。
- 任务行显示任务名、说明、负责人、截止时间、卡点。
- 点击任务行打开右侧编辑抽屉。
- 「新增任务」打开空白新增抽屉。
- 「返回项目列表」回到列表页。
- 任务保存后列表刷新、抽屉关闭。
### 业务方案、产品研发、财务管理
与 v1.0 验收标准一致,详见 `OPC管理系统PRD-V1.0.md` 第 11 章。
---
## 9. 版本历史
| 版本 | 日期 | 说明 |
|------|------|------|
| v0.9 | 2026-05-29 | 初始 PRD6 Tab 功能架构 |
| v1.0 | 2026-06-01 | 匹配 v1.0.2,卡片→表格,补充技术栈与交互细节 |
| v2.0 | 2026-06-16 | 匹配 v1.7.6,销售+运营合并为重点项目、6 阶段任务体系、二级页面、任务编辑抽屉、首页指标分层、产品平台分类 |
详细修改日志见 `CHANGELOG.md`

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,29 @@ body {
justify-content: space-between; justify-content: space-between;
} }
/* 工作台侧边栏 */
.workspace-nav-item {
width: 52px;
height: 52px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 14px;
color: #94a3b8;
cursor: pointer;
transition: all 0.15s;
margin-bottom: 2px;
}
.workspace-nav-item:hover {
color: #e2e8f0;
background: rgba(255,255,255,0.08);
}
.workspace-nav-item.active {
color: #60a5fa;
background: rgba(96,165,250,0.15);
}
.tabs { .tabs {
display: flex; display: flex;
gap: 4px; gap: 4px;
@@ -38,6 +61,478 @@ body {
display: block; display: block;
} }
/* 财务模态框 Tab */
.finance-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid #e2e8f0;
padding: 0 32px;
background: #fff;
}
.finance-tab {
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
color: #64748b;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.15s;
}
.finance-tab:hover { color: #1e293b; }
.finance-tab.active {
color: #1d4ed8;
border-bottom-color: #1d4ed8;
}
/* 项目统一卡片布局 */
.project-board {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
height: calc(100vh - 190px);
}
/* 项目树头部 */
.project-tree-hd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
color: #374151;
flex-shrink: 0;
}
.project-search {
display: flex;
align-items: center;
gap: 6px;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 5px 10px;
font-size: 13px;
}
.project-search i {
color: #9ca3af;
flex-shrink: 0;
}
.project-search input {
border: none;
outline: none;
font-size: 13px;
color: #374151;
width: 160px;
background: transparent;
}
.project-search input::placeholder { color: #9ca3af; }
.project-board-body {
display: flex;
flex: 1;
overflow: hidden;
}
/* 项目树 */
.project-tree {
width: 200px;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.project-tree-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.project-tree-node {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 13px;
color: #334155;
cursor: pointer;
transition: background 0.1s;
border-left: 3px solid transparent;
}
.project-tree-node:hover {
background: #f1f5f9;
}
.project-tree-node.active {
background: #eff6ff;
border-left-color: #3b82f6;
color: #1d4ed8;
font-weight: 500;
}
.project-tree-icon {
flex-shrink: 0;
display: flex;
align-items: center;
color: #94a3b8;
}
.project-tree-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-tree-empty {
padding: 20px 16px;
font-size: 13px;
color: #94a3b8;
text-align: center;
}
/* 右键菜单 */
.project-context-menu {
position: fixed;
z-index: 100;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
padding: 4px 0;
min-width: 140px;
}
.project-context-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
font-size: 12px;
color: #334155;
cursor: pointer;
}
.project-context-item:hover {
background: #f1f5f9;
}
.project-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #94a3b8;
font-size: 14px;
}
/* 台账任务流Plane 风格) */
.task-feed {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
min-width: 0;
}
.task-feed-hd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
flex-shrink: 0;
}
.task-feed-body {
flex: 1;
overflow-y: auto;
padding: 0;
}
.task-section {
border-bottom: 1px solid #edf2f7;
}
.task-section-hd {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: #fafbfc;
cursor: pointer;
user-select: none;
}
.task-section-toggle {
display: flex;
align-items: center;
color: #9ca3af;
transition: transform 0.15s;
}
.task-section-list-wrap.collapsed {
display: none;
}
.task-section-icon {
display: flex;
align-items: center;
color: #9ca3af;
}
.task-section-label {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.task-section-n {
background: #e5e7eb;
color: #6b7280;
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
.task-section-list {
/* flat list, no card */
}
.task-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 20px;
cursor: pointer;
transition: background 0.1s;
}
.task-item:hover {
background: #f9fafb;
}
.task-item.task-done .task-title {
text-decoration: line-through;
color: #9ca3af;
}
/* 状态徽章 */
.task-status-badge {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.status-未开始 { background: #f1f5f9; color: #64748b; }
.status-进行中 { background: #dbeafe; color: #1d4ed8; }
.status-验收中 { background: #fef3c7; color: #92400e; }
.status-已结束 { background: #dcfce7; color: #166534; }
/* 产品版本状态 */
.status-规划中 { background: #f1f5f9; color: #64748b; }
.status-设计中 { background: #ede9fe; color: #7c3aed; }
.status-开发中 { background: #dbeafe; color: #1d4ed8; }
.status-测试中 { background: #fef3c7; color: #92400e; }
.status-已上线 { background: #dcfce7; color: #166534; }
.status-已延期 { background: #fee2e2; color: #991b1b; }
.status-已取消 { background: #f1f5f9; color: #94a3b8; }
/* 优先级底色 */
.task-p0 { background: #fef2f2; }
.task-p0:hover { background: #fee2e2; }
.task-p1 { background: #fffbeb; }
.task-p1:hover { background: #fef3c7; }
.task-priority-badge {
flex-shrink: 0;
font-size: 11px;
font-weight: 700;
padding: 1px 6px;
border-radius: 4px;
}
.priority-p0 { background: #fecaca; color: #991b1b; }
.priority-p1 { background: #fde68a; color: #92400e; }
.priority-p2 { background: #e2e8f0; color: #475569; }
.priority-p3 { background: #f1f5f9; color: #94a3b8; }
.task-check {
flex-shrink: 0;
display: flex;
align-items: center;
color: #9ca3af;
cursor: pointer;
}
.task-check:hover {
color: #3b82f6;
}
.task-grip {
flex-shrink: 0;
color: #d1d5db;
cursor: grab;
padding: 2px 0;
display: flex;
align-items: center;
}
.task-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.task-feed .task-title {
font-weight: 400;
color: #1f2937;
font-size: 12px;
}
.task-desc {
font-size: 12px;
color: #9ca3af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 360px;
}
.task-meta {
flex-shrink: 0;
font-size: 12px;
color: #6b7280;
min-width: 56px;
text-align: right;
}
.task-blocker {
display: block;
font-size: 12px;
color: #ef4444;
margin-top: 2px;
}
.task-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #9ca3af;
font-size: 13px;
}
/* 业务方案列表项 */
.proposal-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
font-size: 13px;
transition: background 0.1s;
}
/* 上传任务列表 */
#uploadTaskList {
display: none;
padding: 12px 20px;
border-top: 1px solid #e2e8f0;
}
.upload-task {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
font-size: 12px;
}
.upload-task-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #334155;
}
.upload-task-bar {
width: 80px;
height: 4px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
flex-shrink: 0;
}
.upload-task-fill {
height: 100%;
background: #3b82f6;
border-radius: 2px;
transition: width 0.2s;
}
.upload-task-pct {
width: 32px;
text-align: right;
color: #6b7280;
font-size: 11px;
flex-shrink: 0;
}
.upload-task-cancel {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
color: #9ca3af;
cursor: pointer;
border-radius: 4px;
}
.upload-task-cancel:hover { color: #ef4444; background: #fef2f2; }
.proposal-item:hover { background: #f9fafb; }
.proposal-customer {
flex: 0 0 140px;
font-weight: 500;
color: #1f2937;
}
.proposal-notes {
flex: 1;
color: #6b7280;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.proposal-files {
flex-shrink: 0;
font-size: 12px;
color: #9ca3af;
}
.card { .card {
background: white; background: white;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
@@ -489,3 +984,96 @@ td {
color: #64748b; color: #64748b;
margin: 4px 0; margin: 4px 0;
} }
/* Task Modal — Plane style */
.task-modal { display: none; }
.task-modal.active { display: block; }
.task-overlay {
position: fixed; inset: 0; background: rgba(15,23,42,0.35); z-index: 200;
display: flex; align-items: flex-start; justify-content: center;
padding-top: 48px; overflow-y: auto;
}
.task-panel {
background: #fff; border-radius: 12px; width: 1000px; max-width: 96vw;
box-shadow: 0 20px 60px rgba(0,0,0,0.12); margin-bottom: 48px;
overflow: hidden;
}
.task-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid #e2e8f0;
}
.task-drawer .task-title { color: #1e293b; font-size: 15px; font-weight: 600; }
.task-close {
color: #94a3b8; background: none; border: none; cursor: pointer;
padding: 4px; border-radius: 6px; display: flex;
}
.task-close:hover { color: #475569; background: #f1f5f9; }
.task-body-wrap, .task-page-wrap { position: relative; overflow: hidden; }
.task-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; transition: margin-right 0.25s ease; }
.task-drawer {
position: fixed; top: 0; right: 0; width: 420px; height: 100vh;
background: #fff; border-left: 1px solid #e2e8f0;
transform: translateX(100%); transition: transform 0.25s ease;
z-index: 300; display: flex; flex-direction: column;
}
.task-drawer.open { transform: translateX(0); }
.task-drawer-hd {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-bottom: 1px solid #e2e8f0;
}
.task-drawer-title { font-size: 15px; font-weight: 600; color: #1e293b; }
.task-drawer-form { padding: 16px 20px; flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; }
.task-group {
background: #fff; border-radius: 8px;
border: 1px solid #e2e8f0; overflow: hidden;
}
.task-group-hd {
display: flex; align-items: center; gap: 8px;
padding: 10px 16px; background: #f8fafc;
}
.task-group-icon { color: #64748b; display: flex; }
.task-group-label { color: #334155; font-size: 13px; font-weight: 600; }
.task-group-n {
background: #e2e8f0; color: #64748b; font-size: 11px;
padding: 1px 7px; border-radius: 10px;
}
.task-group-list { display: flex; flex-direction: column; }
.task-group-list.drag-over { background: #f0f9ff; }
.task-row {
display: flex; align-items: center; gap: 16px;
padding: 10px 16px; border-top: 1px solid #f1f5f9;
cursor: pointer; transition: background 0.15s;
}
.task-row.dragging { opacity: 0.4; background: #f1f5f9; }
.task-row.task-done .task-name { text-decoration: line-through; color: #94a3b8; }
.task-row:hover { background: #f8fafc; }
.task-dot { display: flex; color: #cbd5e1; flex-shrink: 0; cursor: pointer; }
.task-dot:hover { color: #6366f1; }
.task-grip { display: flex; color: #cbd5e1; flex-shrink: 0; cursor: grab; }
.task-grip:hover { color: #94a3b8; }
.task-grip:active { cursor: grabbing; }
.task-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.task-name { color: #1e293b; font-size: 13px; }
.task-desc { color: #94a3b8; font-size: 12px; word-wrap: break-word; overflow-wrap: break-word; }
.task-blocker { color: #dc2626; font-size: 12px; word-wrap: break-word; overflow-wrap: break-word; }
.task-col { color: #64748b; font-size: 12px; white-space: nowrap; width: 100px; text-align: right; }
.task-col-badge { color: #64748b; font-size: 12px; white-space: nowrap; width: 90px; text-align: right; }
.task-none { color: #94a3b8; font-size: 13px; padding: 12px 14px; text-align: center; border-top: 1px solid #f1f5f9; }
.task-form {
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 14px; margin-bottom: 16px;
}
.task-field { display: flex; flex-direction: column; gap: 4px; }
.task-field span { color: #64748b; font-size: 12px; }
.task-field input, .task-field select, .task-field textarea {
background: #fff; border: 1px solid #e2e8f0; border-radius: 6px;
color: #1e293b; font-size: 13px; padding: 6px 10px; outline: none;
}
.task-field input:focus, .task-field select:focus, .task-field textarea:focus { border-color: #2563eb; }
.col-span-2 { grid-column: span 2; }
.task-group-add {
display: block; width: 100%; padding: 10px; text-align: center;
color: #6b6d75; font-size: 13px; background: none; border: none;
border-top: 1px solid #24272d; cursor: pointer;
}
.task-group-add:hover { color: #e4e5e7; background: #24272d; }

20
static/vendor/chart.js vendored Normal file

File diff suppressed because one or more lines are too long

12
static/vendor/lucide.js vendored Normal file

File diff suppressed because one or more lines are too long

5015
static/vendor/squire.js vendored Normal file

File diff suppressed because it is too large Load Diff

83
static/vendor/tailwind.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>科普慰心斋OPC 工作台</title> <title>科普慰心斋OPC 工作台</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="{{ url_for('static', filename='vendor/tailwind.js') }}"></script>
<script> <script>
tailwind.config = { tailwind.config = {
theme: { theme: {
@@ -21,38 +21,106 @@
} }
</script> </script>
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.8/dist/chart.umd.min.js"></script> <script src="{{ url_for('static', filename='vendor/chart.js') }}" defer></script>
<script src="https://cdn.jsdelivr.net/npm/squire-rte@1/build/squire-raw.js"></script> <script src="{{ url_for('static', filename='vendor/squire.js') }}" defer></script>
<script src="https://unpkg.com/lucide@latest"></script> <script src="{{ url_for('static', filename='vendor/lucide.js') }}" defer></script>
</head> </head>
<body class="min-h-screen bg-slate-50 text-slate-950"> <body class="min-h-screen bg-slate-50 text-slate-950">
<div class="flex min-h-screen">
<!-- 左侧工作台切换栏 -->
<aside class="w-[72px] bg-slate-900 flex flex-col items-center py-6 gap-1 shrink-0" id="workspaceSidebar">
<div class="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center mb-6 text-white font-bold text-sm">OPC</div>
<div class="workspace-nav-item active" data-tenant="科普·无界" onclick="switchTenant('科普·无界')" title="科普·无界">
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
<span class="text-[10px] mt-1">科普</span>
</div>
<div class="workspace-nav-item" data-tenant="科研·无界" onclick="switchTenant('科研·无界')" title="科研·无界">
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
<span class="text-[10px] mt-1">科研</span>
</div>
<div class="workspace-nav-item" data-tenant="医患·无界" onclick="switchTenant('医患·无界')" title="医患·无界">
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
<span class="text-[10px] mt-1">医患</span>
</div>
<!-- 用户区 -->
<div class="mt-auto flex flex-col items-center gap-2 pt-4">
<div class="w-8 h-8 rounded-full bg-slate-700 flex items-center justify-center text-white text-[11px] font-medium" id="userAvatar" title=""></div>
<button class="text-[10px] text-slate-500 hover:text-red-400 transition-colors" onclick="doLogout()" title="退出登录">退出</button>
</div>
</aside>
<!-- 主内容区 -->
<div class="flex-1 min-w-0">
<header class="topbar border-b border-slate-200 bg-white px-8 py-5"> <header class="topbar border-b border-slate-200 bg-white px-8 py-5">
<div> <div class="flex items-center gap-3">
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager · 单用户 · 单项目</p> <div>
<h1 class="mt-1 text-2xl font-semibold">科普慰心斋OPC 工作台</h1> <p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager</p>
<div class="flex items-center gap-3 mt-1">
<h1 class="text-2xl font-semibold" id="workspaceTitle">科普 OPC 工作台</h1>
</div>
</div>
</div> </div>
<button id="refreshBtn" class="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium hover:bg-slate-50" type="button"><i data-lucide="refresh-cw"></i>刷新</button>
</header> </header>
<nav class="tabs border-b border-slate-200 bg-white px-8" id="tabs"> <nav class="tabs border-b border-slate-200 bg-white px-8" id="tabs">
<button class="active" data-tab="home"><i data-lucide="home"></i>首页</button> <button class="active" data-tab="home"><i data-lucide="home"></i>首页</button>
<button data-tab="sales"><i data-lucide="briefcase-business"></i>销售管理</button> <button data-tab="finance"><i data-lucide="briefcase-business"></i>经营管理</button>
<button data-tab="proposals"><i data-lucide="file-text"></i>业务方案</button> <button data-tab="projects"><i data-lucide="file-text"></i>重点工作与台账</button>
<button data-tab="operations"><i data-lucide="activity"></i>运营管理</button> <button data-tab="proposals"><i data-lucide="package"></i>业务方案</button>
<button data-tab="products"><i data-lucide="package"></i>产品研发</button> <button data-tab="products"><i data-lucide="wallet-cards"></i>产品迭代</button>
<button data-tab="finance"><i data-lucide="wallet-cards"></i>财务管理</button>
</nav> </nav>
<main class="px-8 py-6"> <main class="px-8 py-6">
<section id="home" class="panel active"></section> <section id="home" class="panel active"></section>
<section id="sales" class="panel"></section> <section id="projects" class="panel"></section>
<section id="proposals" class="panel"></section> <section id="proposals" class="panel"></section>
<section id="operations" class="panel"></section>
<section id="products" class="panel"></section> <section id="products" class="panel"></section>
<section id="finance" class="panel"></section> <section id="finance" class="panel"></section>
</main> </main>
</div><!-- 关闭主内容区 -->
</div><!-- 关闭 flex 容器 -->
<aside id="drawer" class="drawer" aria-hidden="true"></aside> <aside id="drawer" class="drawer" aria-hidden="true"></aside>
<div id="taskModal" class="task-modal"></div>
<div id="transferModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeTransferModal()">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4" onclick="event.stopPropagation()">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<h3 class="text-lg font-semibold text-slate-800">跨工作台转移</h3>
<button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeTransferModal()"><i data-lucide="x"></i></button>
</div>
<form onsubmit="submitTransfer(event)" class="p-6 grid gap-4">
<input type="hidden" name="transfer_resource" id="transfer-resource" value="">
<input type="hidden" name="transfer_id" id="transfer-id" value="">
<p id="transfer-title-text" class="text-sm text-slate-600"></p>
<label class="block"><span class="text-xs font-medium text-slate-500">目标工作台</span>
<select name="transfer_tenant" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm">
<option value="科普·无界">科普·无界</option>
<option value="科研·无界">科研·无界</option>
<option value="医患·无界">医患·无界</option>
</select>
</label>
<div class="flex justify-end gap-3 pt-3">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeTransferModal()">取消</button>
<button type="submit" class="btn btn-primary btn-sm">确认转移</button>
</div>
</form>
</div>
</div>
<!-- 新增项目模态框 -->
<div id="newProjectModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeNewProjectModal()">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onclick="event.stopPropagation()">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<h3 class="text-lg font-semibold text-slate-800">新增项目</h3>
<button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeNewProjectModal()"><i data-lucide="x"></i></button>
</div>
<form onsubmit="createOperation(event)" class="p-6 grid gap-4">
<label class="block"><span class="text-xs font-medium text-slate-500">项目名称</span><input name="project_name" required class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm"></label>
<label class="block"><span class="text-xs font-medium text-slate-500">项目备注</span><textarea name="notes" rows="3" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm" placeholder="可选"></textarea></label>
<div class="flex justify-end gap-3 pt-3 border-t border-slate-100">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeNewProjectModal()">取消</button>
<button type="submit" class="btn btn-primary btn-sm">创建</button>
</div>
</form>
</div>
</div>
<script src="{{ url_for('static', filename='app.js') }}"></script> <script src="{{ url_for('static', filename='app.js') }}"></script>
</body> </body>
</html> </html>

45
templates/login.html Normal file
View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OPC 工作台 · 登录</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-slate-50 flex items-center justify-center">
<div class="bg-white rounded-2xl shadow-lg p-8 w-full max-w-sm mx-4">
<div class="text-center mb-6">
<h1 class="text-2xl font-bold text-slate-800">OPC 工作台</h1>
<p class="text-sm text-slate-400 mt-1">请输入账号密码登录</p>
</div>
<form id="loginForm" onsubmit="doLogin(event)" class="grid gap-4">
<label class="block">
<span class="text-xs font-medium text-slate-500">账号</span>
<input name="username" required class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm" placeholder="admin / kepu / keyan / yihuan" autofocus>
</label>
<label class="block">
<span class="text-xs font-medium text-slate-500">密码</span>
<input name="password" type="password" required class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm">
</label>
<p id="loginError" class="text-red-500 text-xs hidden"></p>
<button type="submit" class="btn w-full bg-slate-800 text-white rounded-lg py-2.5 text-sm font-medium hover:bg-slate-700">登 录</button>
</form>
<p class="text-xs text-slate-400 text-center mt-6">默认管理员qiukai / yxcowork2026</p>
</div>
<script>
async function doLogin(e) {
e.preventDefault();
const form = e.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
try {
const res = await (await fetch("/api/auth/login", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify(data) })).json();
if (res.error) { document.querySelector("#loginError").textContent = res.error; document.querySelector("#loginError").classList.remove("hidden"); return; }
window.location.href = "/";
} catch (err) {
document.querySelector("#loginError").textContent = "网络错误,请重试";
document.querySelector("#loginError").classList.remove("hidden");
}
}
</script>
</body>
</html>