Compare commits

...

52 Commits

Author SHA1 Message Date
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
mac
268e6d4f80 v1.0.2: 加入 Codex Skill,说「打开 OPC 工作台」即可启动 2026-05-30 01:12:44 +08:00
14 changed files with 8083 additions and 110 deletions

View File

@@ -1,7 +1,36 @@
# OPC Manager Version Log
## v1.0.1 — 2026-05-30
## 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
- 新增 Codex Skill`opc-manager`,说"打开 OPC 工作台"即可在任意上下文启动
## v1.0.1 — 2026-05-30
- 将 data/opc.sqlite 加入 .gitignore避免运行时数据库被误提交
## v1.0.0 — 2026-05-30

View File

@@ -141,9 +141,26 @@ CREATE TABLE IF NOT EXISTS file_assets (
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS project_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
phase TEXT NOT NULL DEFAULT '',
milestone TEXT NOT NULL DEFAULT '',
task TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL DEFAULT '',
due_date TEXT NOT NULL DEFAULT '',
blockers TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
"""
)
# Schema migrations
try: conn.execute("ALTER TABLE product_versions ADD COLUMN platform TEXT NOT NULL DEFAULT ''")
except: pass
if one(conn, "SELECT id FROM sales_leads LIMIT 1"):
conn.close()
return
@@ -210,15 +227,15 @@ CREATE TABLE IF NOT EXISTS file_assets (
add_file_index(conn, "operation", project_id, version, category, op_dir / filename, external=True)
products = [
("妙手医生服务小程序", "v1.1", "视频任务增强 + 积分商城", "草稿箱、批量上传、积分商城、消息通知", "2026-Q3", "规划中"),
("数字化营销后台管理系统", "v1.2", "运营数据看板 + 智能审核", "医生活跃、任务完成率、AI 预审、渠道数据上报", "2026-Q3", "设计中"),
("妙手患者服务", "v0.5", "科普浏览 + 医生主页 MVP", "科普文章/视频浏览、医生主页、搜索", "2026-Q3", "规划中"),
("数字人内容平台", "v0.1", "基础数字人视频生成 MVP", "预设形象、AI 配音、脚本驱动、简单模板", "2026-Q3", "规划中"),
("渠道分发引擎", "v1.0", "六渠道统一分发", "分发 API、内容适配、分发排期、效果追踪", "2027-Q1", "规划中"),
("妙手医生服务小程序", "v1.1", "视频任务增强 + 积分商城", "草稿箱、批量上传、积分商城、消息通知", "2026-Q3", "规划中", "科普平台"),
("数字化营销后台管理系统", "v1.2", "运营数据看板 + 智能审核", "医生活跃、任务完成率、AI 预审、渠道数据上报", "2026-Q3", "设计中", "真研平台"),
("妙手患者服务", "v0.5", "科普浏览 + 医生主页 MVP", "科普文章/视频浏览、医生主页、搜索", "2026-Q3", "规划中", "科普平台"),
("数字人内容平台", "v0.1", "基础数字人视频生成 MVP", "预设形象、AI 配音、脚本驱动、简单模板", "2026-Q3", "规划中", "科普平台"),
("渠道分发引擎", "v1.0", "六渠道统一分发", "分发 API、内容适配、分发排期、效果追踪", "2027-Q1", "规划中", "科普平台"),
]
for product in products:
cur = conn.execute(
"INSERT INTO product_versions (product_name,version,version_goal,feature_list,launch_date,status) VALUES (?,?,?,?,?,?)",
"INSERT INTO product_versions (product_name,version,version_goal,feature_list,launch_date,status,platform) VALUES (?,?,?,?,?,?,?)",
product,
)
conn.execute(
@@ -238,6 +255,25 @@ CREATE TABLE IF NOT EXISTS file_assets (
(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:
conn.execute(
"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.close()
@@ -280,13 +316,33 @@ def attach_common(conn, resource, 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 = []
for item in rows(conn, "SELECT DISTINCT month FROM finance_records ORDER BY month"):
month = item["month"]
revenue = one(conn, "SELECT COALESCE(SUM(amount),0) AS v FROM finance_records WHERE month=? AND record_type='revenue'", (month,))["v"]
cost = one(conn, "SELECT COALESCE(SUM(amount),0) AS v FROM finance_records WHERE month=? AND record_type='cost_expense'", (month,))["v"]
data.append({"month": month, "revenue": revenue, "gross_profit": revenue - cost, "cost_expense": cost, "net_profit": revenue - cost})
for month in months:
col_month = month.replace("-", "_")
col_rev = f"rev_{col_month}"
col_gross = f"gross_{col_month}"
# 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
@@ -297,16 +353,43 @@ def index():
@app.route("/api/bootstrap")
def bootstrap():
tenant = request.args.get("tenant", "科普·无界")
conn = db()
try:
sales = attach_common(conn, "sales", rows(conn, "SELECT * FROM sales_leads ORDER BY id DESC"))
proposals = attach_common(conn, "proposals", rows(conn, "SELECT * FROM business_proposals ORDER BY id DESC"))
operations = attach_common(conn, "operations", rows(conn, "SELECT * FROM operation_projects ORDER BY id DESC"))
products = attach_common(conn, "products", rows(conn, "SELECT * FROM product_versions ORDER BY id DESC"))
finance = rows(conn, "SELECT * FROM finance_records ORDER BY month DESC, id DESC")
current_month = "2026-05"
revenue = sum(x["amount"] for x in finance if x["month"] == current_month and x["record_type"] == "revenue")
cost = sum(x["amount"] for x in finance if x["month"] == current_month and x["record_type"] == "cost_expense")
def q(sql, *args):
return rows(conn, sql, args)
sales = attach_common(conn, "sales", q("SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", tenant))
proposals = attach_common(conn, "proposals", q("SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", tenant))
operations = attach_common(conn, "operations", q("SELECT * FROM operation_projects WHERE tenant=? ORDER BY id DESC", tenant))
products = attach_common(conn, "products", q("SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", tenant))
finance = q("SELECT * FROM finance_records WHERE tenant=? ORDER BY month DESC, id DESC", tenant)
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 = {
"project_name": "科普(慰心斋)",
"metrics": {
@@ -314,24 +397,41 @@ def bootstrap():
"active_sales": len([x for x in sales if x["status"] in ["待跟进", "跟进中", "方案中", "商务谈判"]]),
"execution_projects": len([x for x in operations if x["project_type"] == "execution"]),
"risk_projects": len([x for x in operations if x["project_status"] == "有风险" or x["risks"]]),
"monthly_revenue": revenue,
"monthly_net_profit": revenue - cost,
"monthly_revenue": rev_month,
"monthly_net_profit": gross_month,
"monthly_gross": gross_month,
"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],
}
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": ["科普·无界","科研·无界","医患·无界"]})
finally:
conn.close()
TABLES = {
"sales": ("sales_leads", ["target_customer", "priority", "status"]),
"proposals": ("business_proposals", ["customer_or_project_name", "version", "description", "status", "created_date"]),
"operations": ("operation_projects", ["project_name", "project_version", "project_type", "project_status", "current_stage", "owner", "target_customer", "customer_need", "expected_contract_amount", "expected_sign_date", "sign_probability", "next_action", "sop_stage", "execution_progress", "current_deliverable", "risks", "notes"]),
"products": ("product_versions", ["product_name", "version", "version_goal", "feature_list", "launch_date", "status", "notes"]),
"finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes"]),
"sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]),
"proposals": ("business_proposals", ["customer_or_project_name", "version", "description", "status", "created_date", "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", "tenant"]),
"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", "tenant"]),
"tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "tenant"]),
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "rev_2026_06", "rev_2026_07", "rev_2026_08", "rev_2026_09", "gross_2026_06", "gross_2026_07", "gross_2026_08", "gross_2026_09"]),
}
@@ -414,6 +514,19 @@ def delete_followup(followup_id):
conn.close()
@app.route("/api/tasks/batch-sort", methods=["POST"])
def batch_sort_tasks():
conn = db()
try:
items = request.get_json(force=True).get("items", [])
for item in items:
conn.execute("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"])
def upload_file():
file = request.files["file"]

View File

@@ -1 +1,2 @@
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`

View File

@@ -1,8 +1,13 @@
const state = {
active: "home",
data: null,
tenant: "科普·无界",
opFilter: "all",
finFilter: "已签单",
projectView: null,
chart: null,
chart2: null,
productPlatform: "all",
};
const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")}`;
@@ -45,7 +50,7 @@ function renderTable(headers, rows, rowClicks) {
}
async function load() {
state.data = await api("/api/bootstrap");
state.data = await api(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`);
render();
}
@@ -59,9 +64,8 @@ function switchTab(tab) {
function render() {
if (!state.data) return;
renderHome();
renderSales();
renderProjects();
renderProposals();
renderOperations();
renderProducts();
renderFinance();
if (window.lucide) window.lucide.createIcons();
@@ -92,19 +96,38 @@ function render() {
function renderHome() {
const { summary, financeMonthly } = state.data;
const m = summary.metrics;
const rows1 = [
["年度累计签约", money(m.signed_annual || m.signed_amount)],
["Q2 累计签约", money(m.signed_q2 || 0)],
["本月新增签约", money(m.signed_month || 0)],
["合同流程中", money(m.pipeline_amount)],
];
const rows2 = [
["年度累计确收", money(m.revenue_annual)],
["Q2 累计确收", money(m.revenue_q2)],
["本月新增确收", money(m.monthly_revenue)],
["已签约未执行", money(m.signed_not_executed)],
];
const rows3 = [
["年度累计毛利", money(m.gross_annual)],
["Q2 累计毛利", money(m.gross_q2)],
["本月新增毛利", money(m.monthly_net_profit)],
["合同毛利率", m.revenue_annual ? Math.round(m.gross_annual / m.revenue_annual * 100) + "%" : "—"],
];
const tblCard = (title, rows) => card(`<h3 class="text-sm font-bold text-slate-700 mb-3">${title}</h3><table class="w-full text-sm"><tbody>${rows.map(([label, value]) => `<tr class="border-b border-slate-100 last:border-0"><td class="py-2 pr-4 text-slate-500">${label}</td><td class="py-2 text-right font-semibold text-slate-800">${value}</td></tr>`).join("")}</tbody></table>`, "p-4");
document.querySelector("#home").innerHTML = `
<div class="grid gap-5">
<div class="grid grid-cols-4 gap-3">
<div class="grid grid-cols-6 gap-3">
${[
["P0 客户数", m.p0_customers, "sales"],
["跟进中销售机会", m.active_sales, "sales"],
["已签约执行项目", m.execution_projects, "operations"],
["有风险项目", m.risk_projects, "operations"],
["本月收入", money(m.monthly_revenue), "finance"],
["重点项目", m.total_projects, "projects"],
["业务方案", m.total_proposals, "proposals"],
["产品版本", m.total_products, "products"],
["本月确收", money(m.monthly_revenue), "finance"],
["本月毛利", money(m.monthly_gross || m.monthly_net_profit), "finance"],
["本月净利", money(m.monthly_net_profit), "finance"],
["即将上线版本", m.upcoming_products, "products"],
].map(([label, value, tab]) => `<button class="metric-card" onclick="switchTab('${tab}')"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="gauge"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></button>`).join("")}
</div>
<div class="grid grid-cols-3 gap-5">${tblCard("合同金额", rows1)}${tblCard("确收金额", rows2)}${tblCard("确收毛利", rows3)}</div>
<div class="grid grid-cols-2 gap-5">
${card(`<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">财务趋势</h2>${badge("YYYY-MM")}</div><div style="position:relative;height:140px"><canvas id="financeChart"></canvas></div>`, "p-4")}
${card(`<h2 class="text-lg font-bold">风险提醒</h2><div class="mt-3 grid gap-2">${(summary.risks.length ? summary.risks : [{ title: "暂无高风险", content: "当前无明确阻塞,按周更新即可。" }]).map((r) => `<div class="rounded-md border border-amber-200 bg-amber-50 p-3"><p class="font-bold text-amber-900">${r.title}</p><p class="mt-1 text-sm text-amber-800 break-words">${r.content}</p></div>`).join("")}</div>`, "p-5")}
@@ -143,34 +166,216 @@ function formHtml(fields, button) {
async function createResource(event, resource) {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget).entries());
await api(`/api/${resource}`, { method: "POST", body: JSON.stringify({ data }) });
event.currentTarget.reset();
await load();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
try {
await api(`/api/${resource}`, { method: "POST", body: JSON.stringify({ data }) });
form.reset();
await load();
} catch (error) {
alert("创建失败:" + error.message);
}
}
window.createSales = (event) => createResource(event, "sales");
window.createProposal = (event) => createResource(event, "proposals");
window.createOperation = (event) => createResource(event, "operations");
window.createProduct = (event) => createResource(event, "products");
window.createFinance = (event) => createResource(event, "finance");
window.switchTab = switchTab;
function renderSales() {
const rows = state.data.sales.map((x) => [x.target_customer, badge(x.priority), badge(x.status), text(x.latest_follow_up_record)]);
const salesClicks = state.data.sales.map((x) => ({ resource: "sales", id: x.id }));
document.querySelector("#sales").innerHTML = `<div class="grid gap-4">
${card(formHtml([
{ label: "目标客户", input: `<input name="target_customer" required placeholder="客户名称">` },
{ label: "优先级", input: `<select name="priority"><option>P0</option><option selected>P1</option><option>P2</option><option>P3</option></select>` },
{ label: "状态", input: `<select name="status"><option>待跟进</option><option>跟进中</option><option>方案中</option><option>商务谈判</option><option>已签约</option><option>暂缓</option><option>已丢单</option></select>` },
], { handler: "createSales", text: `<i data-lucide="plus"></i>新增客户` }), "p-4")}
${renderTable(["目标客户", "优先级", "状态", "最新跟进记录"], rows, salesClicks)}
window.openTaskForm = (projectId, taskId) => {
const drawer = document.querySelector(`#task-drawer-${projectId}`);
const titleEl = drawer.querySelector(".task-drawer-title");
if (taskId === null) {
document.querySelector(`#task-id-${projectId}`).value = "";
document.querySelector(`#task-name-${projectId}`).value = "";
document.querySelector(`#task-phase-${projectId}`).value = "商务洽谈";
document.querySelector(`#task-owner-${projectId}`).value = "";
document.querySelector(`#task-due-${projectId}`).value = "";
document.querySelector(`#task-notes-${projectId}`).value = "";
document.querySelector(`#task-blockers-${projectId}`).value = "";
document.querySelector(`#task-submit-btn-${projectId}`).textContent = "确认新增";
if (titleEl) titleEl.textContent = "新增任务";
} else {
const task = (state.data.tasks || []).find((t) => t.id === taskId);
if (!task) return;
document.querySelector(`#task-id-${projectId}`).value = task.id;
document.querySelector(`#task-name-${projectId}`).value = task.task || "";
document.querySelector(`#task-phase-${projectId}`).value = task.phase || "商务洽谈";
document.querySelector(`#task-owner-${projectId}`).value = task.owner || "";
document.querySelector(`#task-due-${projectId}`).value = task.due_date || "";
document.querySelector(`#task-notes-${projectId}`).value = task.notes || "";
document.querySelector(`#task-blockers-${projectId}`).value = task.blockers || "";
document.querySelector(`#task-submit-btn-${projectId}`).textContent = "保存修改";
if (titleEl) titleEl.textContent = "编辑任务";
}
drawer.classList.add("open");
};
window.closeTaskDrawer = (projectId) => {
document.querySelector(`#task-drawer-${projectId}`).classList.remove("open");
};
window.submitTaskForm = async (event, projectId) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget).entries());
data.project_id = Number(projectId);
const taskId = data.task_id;
delete data.task_id;
try {
if (taskId) {
await api(`/api/tasks/${taskId}`, { method: "PUT", body: JSON.stringify({ data }) });
} else {
await api("/api/tasks", { method: "POST", body: JSON.stringify({ data }) });
}
await load();
} catch (error) {
alert("保存失败:" + error.message);
}
};
window.createFinance = async (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
data.tenant = state.tenant;
data.sign_amount = parseFloat(data.sign_amount) || 0;
for (const m of ["2026-06","2026-07","2026-08","2026-09"]) {
const k = m.replace("-","_");
data["rev_"+k] = parseFloat(data["rev_"+k]) || 0;
data["gross_"+k] = parseFloat(data["gross_"+k]) || 0;
}
const pfId = data.pf_id;
delete data.pf_id;
try {
if (pfId) {
await api("/api/projectFinances/" + pfId, { method: "PUT", body: JSON.stringify({ data }) });
} else {
await api("/api/projectFinances", { method: "POST", body: JSON.stringify({ data }) });
}
form.reset();
document.querySelector("#pf-id-input").value = "";
document.querySelector("#financeModalTitle").textContent = "新增项目财务";
closeFinanceModal();
await load();
} catch (error) {
alert("保存失败:" + error.message);
}
};
window.switchTab = switchTab;
window.switchTenant = (tenant) => {
state.tenant = tenant;
state.projectView = null;
const label = tenant.replace("·无界", "");
document.querySelector("#workspaceTitle").textContent = label + " OPC 工作台";
load();
};
function renderProjects() {
// 二级页面:项目任务详情
if (state.projectView) {
return renderProjectTasks(state.projectView);
}
const items = state.data.operations;
const rows = items.map((x) => [
`<strong>${x.project_name}</strong>`,
text(x.customer_need || x.notes),
badge(x.current_stage || x.project_status),
x.expected_contract_amount ? money(x.expected_contract_amount) : "—",
text(x.owner || "—"),
`<button class="btn btn-ghost btn-sm text-blue-600" onclick="event.stopPropagation(); state.projectView=${x.id}; renderProjects()"><i data-lucide="eye"></i>查看</button>`
]);
document.querySelector("#projects").innerHTML = `<div class="grid gap-4">
<div id="project-form">
${card(formHtml([
{ label: "项目名称", input: `<input name="project_name" required>` },
{ label: "当前阶段", input: `<select name="current_stage"><option>商务洽谈</option><option>线</option><option></option><option></option><option>线广</option><option></option></select>` },
{ label: "项目金额", input: `<input name="expected_contract_amount" type="number" step="0.01" placeholder="万元">` },
{ label: "负责人", input: `<input name="owner">` },
], { handler: "createOperation", text: "新增项目" }), "p-4")}
</div>
${renderTable(["项目", "项目说明", "当前阶段", "项目金额", "负责人", "进展"], rows, items.map((x) => ({ resource: "operations", id: x.id })))}
</div>`;
if (window.lucide) window.lucide.createIcons();
}
function renderProjectTasks(projectId) {
const project = state.data.operations.find((x) => x.id === projectId);
if (!project) { state.projectView = null; renderProjects(); return; }
const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId);
const phases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"];
document.querySelector("#projects").innerHTML = `<div class="grid gap-4">
<div class="flex items-center justify-between px-5">
<button class="btn btn-ghost btn-sm" onclick="state.projectView=null;renderProjects()"><i data-lucide="arrow-left"></i>返回项目列表</button>
<div class="flex items-center gap-3">
<span class="text-sm font-semibold text-slate-700">${project.project_name}</span>
<button class="btn btn-primary btn-sm" onclick="openTaskForm(${projectId}, null)"><i data-lucide="plus"></i>新增任务</button>
</div>
</div>
<div class="task-page-wrap">
<div class="task-body">
${phases.map((phase) => {
const pt = tasks.filter((t) => t.phase === phase);
if (!pt.length) return "";
return `<div class="task-group"><div class="task-group-hd"><span class="task-group-icon"><i data-lucide="layers"></i></span><span class="task-group-label">${phase}</span><span class="task-group-n">${pt.length}</span></div><div class="task-group-list" data-phase="${phase}" ondrop="handleTaskDrop(event, ${projectId}, '${phase}')" ondragover="event.preventDefault(); event.currentTarget.classList.add('drag-over')" ondragleave="event.currentTarget.classList.remove('drag-over')">${pt.map((t) => `<div class="task-row ${t.status === 'done' ? 'task-done' : ''}" data-id="${t.id}" draggable="true" ondragstart="handleTaskDragStart(event, ${t.id})" ondragend="event.currentTarget.classList.remove('dragging')"><span class="task-dot" onclick="event.stopPropagation(); toggleTaskDone(${t.id}, ${projectId})"><i data-lucide="${t.status === 'done' ? 'check-circle' : 'circle'}"></i></span><span class="task-grip"><i data-lucide="grip-vertical"></i></span><div class="task-main" onclick="openTaskForm(${projectId}, ${t.id})"><span class="task-name">${t.task}</span>${t.notes ? `<span class="task-desc">${t.notes}</span>` : ""}${t.blockers ? `<span class="task-blocker">⚠ ${t.blockers}</span>` : ""}</div><span class="task-col">${t.owner || ""}</span><span class="task-col-badge">${t.due_date || ""}</span></div>`).join("")}</div></div>`;
}).join("")}
</div>
<div id="task-drawer-${projectId}" class="task-drawer">
<div class="task-drawer-hd"><span class="task-drawer-title">编辑任务</span><div class="flex items-center gap-2"><button type="button" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" onclick="deleteTask(${projectId})"><i data-lucide="trash-2"></i>删除</button><button class="task-close" onclick="closeTaskDrawer(${projectId})"><i data-lucide="x"></i></button></div></div>
<form class="task-drawer-form" onsubmit="submitTaskForm(event, ${projectId})">
<input type="hidden" name="task_id" id="task-id-${projectId}" value="">
<label class="task-field"><span>任务名称</span><input name="task" required id="task-name-${projectId}"></label>
<label class="task-field"><span>任务阶段</span><select name="phase" id="task-phase-${projectId}">${phases.map((p) => `<option>${p}</option>`).join("")}</select></label>
<label class="task-field"><span>负责人</span><input name="owner" id="task-owner-${projectId}"></label>
<label class="task-field"><span>截止时间</span><input name="due_date" type="date" id="task-due-${projectId}"></label>
<label class="task-field"><span>任务说明</span><textarea name="notes" rows="3" id="task-notes-${projectId}"></textarea></label>
<label class="task-field"><span>卡点&备注</span><textarea name="blockers" rows="2" id="task-blockers-${projectId}" placeholder="风险卡点、依赖项等"></textarea></label>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-slate-100">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeTaskDrawer(${projectId})">取消</button>
<button type="submit" class="btn btn-primary btn-sm" id="task-submit-btn-${projectId}">确认新增</button>
</div>
</form>
</div>
</div>
</div>`;
if (window.lucide) window.lucide.createIcons();
}
function showTaskModal(projectId) {
const project = state.data.operations.find((x) => x.id === projectId);
const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId);
const phases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"];
document.querySelector("#taskModal").innerHTML = `<div class="task-overlay" onclick="closeTaskModal()"><div class="task-panel" onclick="event.stopPropagation()"><div class="task-header"><h2 class="task-title">${project.project_name} · 任务清单</h2><div class="flex items-center gap-3"><button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); openTaskForm(${projectId}, null)"><i data-lucide="plus"></i>新增任务</button><button class="task-close" onclick="closeTaskModal()"><i data-lucide="x"></i></button></div></div><div class="task-body-wrap">
<div class="task-body">
${phases.map((phase) => {
const pt = tasks.filter((t) => t.phase === phase);
if (!pt.length) return "";
return `<div class="task-group"><div class="task-group-hd"><span class="task-group-icon"><i data-lucide="layers"></i></span><span class="task-group-label">${phase}</span><span class="task-group-n">${pt.length}</span></div><div class="task-group-list">${pt.map((t) => `<div class="task-row" data-id="${t.id}" onclick="event.stopPropagation(); openTaskForm(${projectId}, ${t.id})"><span class="task-dot"><i data-lucide="${t.status === 'done' ? 'check-circle' : 'circle'}"></i></span><div class="task-main"><span class="task-name">${t.task}</span>${t.notes ? `<span class="task-desc">${t.notes}</span>` : ""}${t.blockers ? `<span class="task-blocker">⚠ ${t.blockers}</span>` : ""}</div><span class="task-col">${t.owner || ""}</span><span class="task-col-badge">${t.due_date || ""}</span></div>`).join("")}</div></div>`;
}).join("")}
</div>
<div id="task-drawer-${projectId}" class="task-drawer">
<div class="task-drawer-hd"><span class="task-drawer-title">编辑任务</span><button class="task-close" onclick="closeTaskDrawer(${projectId})"><i data-lucide="x"></i></button></div>
<form class="task-drawer-form" onsubmit="submitTaskForm(event, ${projectId})">
<input type="hidden" name="task_id" id="task-id-${projectId}" value="">
<label class="task-field"><span>任务名称</span><input name="task" required id="task-name-${projectId}"></label>
<label class="task-field"><span>任务阶段</span><select name="phase" id="task-phase-${projectId}">${phases.map((p) => `<option>${p}</option>`).join("")}</select></label>
<label class="task-field"><span>负责人</span><input name="owner" id="task-owner-${projectId}"></label>
<label class="task-field"><span>截止时间</span><input name="due_date" type="date" id="task-due-${projectId}"></label>
<label class="task-field"><span>任务说明</span><textarea name="notes" rows="3" id="task-notes-${projectId}"></textarea></label>
<label class="task-field"><span>卡点&备注</span><textarea name="blockers" rows="2" id="task-blockers-${projectId}" placeholder="风险卡点、依赖项等"></textarea></label>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-slate-100">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeTaskDrawer(${projectId})">取消</button>
<button type="submit" class="btn btn-primary btn-sm" id="task-submit-btn-${projectId}">确认新增</button>
</div>
</form>
</div>
</div></div></div>`;
document.querySelector("#taskModal").classList.add("active");
if (window.lucide) window.lucide.createIcons();
}
window.closeTaskModal = () => {
document.querySelector("#taskModal").classList.remove("active");
document.querySelector("#taskModal").innerHTML = "";
};
function renderProposals() {
const categories = ["方案", "成本", "SOP", "财务流程"];
const proposalRows = state.data.proposals.map((p) => [p.customer_or_project_name, p.version, badge(p.status), p.files.length + " 个"]);
const proposalClicks = state.data.proposals.map((p) => ({ resource: "proposals", id: p.id }));
document.querySelector("#proposals").innerHTML = `<div class="grid gap-4">
@@ -214,54 +419,115 @@ window.uploadFile = async (event, module, ownerId, version, category) => {
await load();
};
function renderOperations() {
const items = state.opFilter === "all" ? state.data.operations : state.data.operations.filter((x) => x.project_type === state.opFilter);
const opRows = items.map((x) => [`<strong>${x.project_name}</strong><p class="text-xs text-slate-500">${x.project_version}</p>`, badge(x.project_type), badge(x.project_status), text(x.current_stage || x.sop_stage), `${x.files.length}`, text(x.latest_follow_up_record)]);
const opClicks = items.map((x) => ({ resource: "operations", id: x.id }));
document.querySelector("#operations").innerHTML = `<div class="grid gap-4">
${card(formHtml([
{ label: "项目名称", input: `<input name="project_name" required>` },
{ label: "项目版本", input: `<input name="project_version" value="v1.0">` },
{ label: "项目类型", input: `<select name="project_type"><option value="opportunity">业务机会项目</option><option value="execution"></option></select>` },
{ label: "状态", input: `<input name="project_status" value="线索发现">` },
], { handler: "createOperation", text: "新增项目" }), "p-4")}
<div class="flex gap-2">${[["all","全部项目"],["opportunity","业务机会项目"],["execution","已签约执行项目"]].map(([k,v]) => `<button class="btn ${state.opFilter === k ? "btn-primary" : "btn-ghost"}" onclick="state.opFilter='${k}'; renderOperations()">${v}</button>`).join("")}</div>
${renderTable(["项目名称", "类型", "状态", "当前阶段", "交付文件", "最新跟进"], opRows, opClicks)}
</div>`;
}
function renderProducts() {
const items = state.productPlatform === "all" ? state.data.products : state.data.products.filter((x) => (x.platform || "") === state.productPlatform);
document.querySelector("#products").innerHTML = `<div class="grid gap-4">
${card(formHtml([
{ label: "产品名称", input: `<input name="product_name" required>` },
{ label: "版本号", input: `<input name="version" required>` },
{ label: "平台", input: `<select name="platform"><option value=""></option><option></option><option></option><option></option></select>` },
{ label: "上线日期", input: `<input name="launch_date" placeholder="2026-Q3">` },
{ label: "状态", input: `<select name="status"><option>规划中</option><option>设计中</option><option>开发中</option><option>测试中</option><option>已上线</option><option>已延期</option><option>已取消</option></select>` },
], { handler: "createProduct", text: "新增版本" }), "p-4")}
${renderTable(["产品名称", "版本号", "版本目标", "核心功能", "上线日期", "状态"], state.data.products.map((p) => [p.product_name, p.version, text(p.version_goal), text(p.feature_list), text(p.launch_date), badge(p.status)]), state.data.products.map((p) => ({ resource: "products", id: p.id })))}
<div class="flex gap-2">${[["all","全部"],["真研平台","真研"],["科普平台","科普"],["关爱平台","关爱"]].map(([k,v]) => `<button class="btn ${state.productPlatform === k ? "btn-primary" : "btn-ghost"}" onclick="state.productPlatform='${k}'; renderProducts()">${v}</button>`).join("")}</div>
${renderTable(["产品名称", "版本号", "版本目标", "核心功能", "平台", "上线日期", "状态"], items.map((p) => [p.product_name, p.version, text(p.version_goal), text(p.feature_list), text(p.platform), text(p.launch_date), badge(p.status)]), items.map((p) => ({ resource: "products", id: p.id })))}
</div>`;
}
function renderFinance() {
const rows = state.data.finance.map((x) => [x.month, badge(x.record_type === "revenue" ? "收入" : "成本/费用"), x.category, money(x.amount), x.occurred_date, text(x.notes)]);
const pfs = state.data.projectFinances || [];
const ops = state.data.operations || [];
const months = ["2026-06","2026-07","2026-08","2026-09"];
const monthLabels = ["6月","7月","8月","9月"];
// Aggregates
const signed = pfs.filter(x => x.status === "已签单");
const pending = pfs.filter(x => x.status !== "已签单");
const sumSign = signed.reduce((s,x) => s + (x.sign_amount||0), 0);
const sumPending = pending.reduce((s,x) => s + (x.sign_amount||0), 0);
const monthRev = months.map(m => pfs.reduce((s,x) => s + (x["rev_"+m.replace("-","_")]||0), 0));
const monthGross = months.map(m => pfs.reduce((s,x) => s + (x["gross_"+m.replace("-","_")]||0), 0));
const renderPfRow = (pf) => {
const mCols = months.map(m => {
const rev = pf["rev_"+m.replace("-","_")] || 0;
const gross = pf["gross_"+m.replace("-","_")] || 0;
return `<td class="p-2 text-right whitespace-nowrap"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
}).join("");
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium">${pf.customer_name}</td><td class="p-2 text-sm">${pf.business_type}</td><td class="p-2 text-sm">${pf.status === "已签单" ? badge("已签") : badge(pf.status,"amber")}</td><td class="p-2 text-right text-sm">${money(pf.sign_amount)}</td>${mCols}<td class="p-2 text-sm text-slate-500">${pf.sales_person || ""}</td></tr>`;
};
document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
${card(`<h2 class="mb-4 text-lg font-bold">收入、毛利、成本/费用、净利月度曲线</h2><canvas id="financeChart2" height="105"></canvas>`, "p-5")}
${card(formHtml([
{ label: "月份", input: `<input name="month" required placeholder="YYYY-MM" pattern="\\d{4}-\\d{2}">` },
{ label: "类型", input: `<select name="record_type"><option value="revenue">收入</option><option value="cost_expense">/</option></select>` },
{ label: "分类", input: `<input name="category" required>` },
{ label: "金额/万", input: `<input name="amount" type="number" step="0.01" required>` },
{ label: "发生日期", input: `<input name="occurred_date" type="date">` },
], { handler: "createFinance", text: "新增明细" }), "p-4")}
${renderTable(["月份", "类型", "分类", "金额", "发生日期", "备注"], rows)}
<div class="grid grid-cols-6 gap-3">
${[["已签项目","" + signed.length],["签约金额",money(sumSign)],["待签项目","" + pending.length],["待签金额",money(sumPending)],["本月确收",money(monthRev[0])],["本月毛利",money(monthGross[0])]].map(([l,v]) => `<div class="bg-white rounded-lg border border-slate-200 p-3 text-center"><p class="text-xs text-slate-500">${l}</p><p class="text-xl font-bold text-slate-800">${v}</p></div>`).join("")}
</div>
${card(`<div class="flex items-center justify-between cursor-pointer" onclick="toggleFinanceChart()"><h2 class="text-lg font-bold text-slate-700">月度趋势</h2><i data-lucide="chevron-down" class="transition-transform rotate-90" id="financeChartIcon"></i></div><div id="financeChartWrap" class="hidden mt-4"><div style="position:relative;height:300px"><canvas id="financeChart2"></canvas></div></div>`, "p-5")}
<div class="flex justify-end"><button class="btn btn-primary btn-sm" onclick="openFinanceModal()"><i data-lucide="plus"></i>新增财务项目</button></div>
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/30" onclick="closeFinanceModal()"><div class="bg-white rounded-xl shadow-xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between"><h3 class="font-bold text-lg" id="financeModalTitle">新增项目财务</h3><button class="btn btn-ghost btn-sm" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div><form onsubmit="createFinance(event)" class="p-6 grid gap-3"><input type="hidden" name="pf_id" id="pf-id-input" value=""><div class="grid grid-cols-3 gap-3"><label class="text-xs">项目<select name="project_id" class="rounded border px-2 py-1.5 text-sm w-full">${ops.map(o => `<option value="${o.id}">${o.project_name}</option>`).join("")}</select></label><label class="text-xs">业务类型<select name="business_type" class="rounded border px-2 py-1.5 text-sm w-full">${["科普音频","科普视频","科普文章","真实世界研究","调研问卷","病例征集","患者招募","医患运营","患者管理","患教会","创新支付","电商","其他"].map(t => `<option>${t}</option>`).join("")}</select></label><label class="text-xs">客户名称<input name="customer_name" class="rounded border px-2 py-1.5 text-sm w-full"></label><label class="text-xs">签约金额/万<input name="sign_amount" type="number" step="0.01" class="rounded border px-2 py-1.5 text-sm w-full"></label><label class="text-xs">签约月份<input name="sign_month" class="rounded border px-2 py-1.5 text-sm w-full"></label><label class="text-xs">状态<select name="status" class="rounded border px-2 py-1.5 text-sm w-full"><option>已签单</option><option>待签</option></select></label></div><div class="grid grid-cols-4 gap-2">${["06","07","08","09"].map(m => `<div class="border rounded p-2"><p class="text-xs text-slate-400 mb-1">${m}月</p><label class="text-xs text-slate-500">确收<br><input name="rev_2026_${m}" type="number" step="0.01" class="w-full rounded border px-1 py-1 text-sm" placeholder="万"></label><label class="text-xs text-slate-500 mt-1">毛利<br><input name="gross_2026_${m}" type="number" step="0.01" class="w-full rounded border px-1 py-1 text-sm" placeholder="万"></label></div>`).join("")}</div><div class="flex justify-end gap-2 pt-3 border-t"><button type="button" class="btn btn-ghost btn-sm" onclick="closeFinanceModal()">取消</button><button type="submit" class="btn btn-primary btn-sm">确认新增</button></div></form></div></div>
${card(`<h3 class="font-bold text-slate-700 mb-3">项目明细 <span class="text-slate-400 font-normal">(${pfs.length})</span></h3><div class="flex gap-2 mb-3">${[["已签单","已签"],["待签","待签"]].map(([k,v]) => `<button class="btn btn-sm ${state.finFilter === k ? 'btn-primary' : 'btn-ghost'}" onclick="state.finFilter='${k}';renderFinance()">${v} (${pfs.filter(x=>x.status===k).length})</button>`).join("")}</div><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-left font-semibold">客户</th><th class="p-2 text-left font-semibold">类型</th><th class="p-2 text-left font-semibold">状态</th><th class="p-2 text-right font-semibold">签约金额</th>${monthLabels.map(l => `<th class="p-2 text-center font-semibold">${l}<br><span class="text-xs text-slate-400">确收/毛利</span></th>`).join("")}<th class="p-2 text-left font-semibold">销售</th></tr></thead><tbody>${pfs.filter(x => x.status === state.finFilter).map(renderPfRow).join("")}</tbody></table></div>`, "p-4")}
</div>`;
renderChartOn("financeChart2", state.data.financeMonthly);
if (window.lucide) window.lucide.createIcons();
}
window.openFinanceModal = () => {
const modal = document.querySelector("#financeModal");
modal.classList.remove("hidden");
};
window.closeFinanceModal = () => {
const modal = document.querySelector("#financeModal");
modal.classList.add("hidden");
};
window.openPfEditModal = (pfId) => {
const pf = (state.data.projectFinances || []).find(x => x.id === pfId);
if (!pf) return;
document.querySelector("#pf-id-input").value = pf.id;
document.querySelector("#financeModalTitle").textContent = "编辑项目财务";
const form = document.querySelector("#financeModal form");
form.querySelector('[name="project_id"]').value = pf.project_id || "";
form.querySelector('[name="business_type"]').value = pf.business_type || "";
form.querySelector('[name="customer_name"]').value = pf.customer_name || "";
form.querySelector('[name="sign_amount"]').value = pf.sign_amount || "";
form.querySelector('[name="sign_month"]').value = pf.sign_month || "";
form.querySelector('[name="status"]').value = pf.status || "已签单";
for (const m of ["06","07","08","09"]) {
form.querySelector('[name="rev_2026_' + m + '"]').value = pf["rev_2026_" + m] || "";
form.querySelector('[name="gross_2026_' + m + '"]').value = pf["gross_2026_" + m] || "";
}
openFinanceModal();
};
window.toggleFinanceChart = () => {
const wrap = document.querySelector("#financeChartWrap");
const icon = document.querySelector("#financeChartIcon");
if (!wrap) return;
wrap.classList.toggle("hidden");
icon.classList.toggle("rotate-90");
if (!wrap.classList.contains("hidden") && !state.chart2) renderFinanceChart();
};
function renderFinanceChart() {
const { financeMonthly } = state.data;
const canvas = document.querySelector("#financeChart2");
if (!canvas || !window.Chart) return;
if (state.chart2) state.chart2.destroy();
state.chart2 = new Chart(canvas, {
type: "line",
data: {
labels: financeMonthly.map((x) => x.month),
datasets: [
{ label: "月度确收", data: financeMonthly.map((x) => x.revenue), borderColor: "#2563eb", tension: 0.3 },
{ label: "月度毛利", data: financeMonthly.map((x) => x.net_profit), borderColor: "#059669", tension: 0.3 },
],
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } }, scales: { x: { ticks: { font: { size: 11 } }, grid: { display: false } }, y: { ticks: { font: { size: 11 }, callback: (v) => v + "万" } } } },
});
}
function renderChartOn(id, data) {
const canvas = document.querySelector(`#${id}`);
if (!canvas || !window.Chart) return;
new Chart(canvas, {
if (state.chart2) state.chart2.destroy();
state.chart2 = new Chart(canvas, {
type: "line",
data: {
labels: data.map((x) => x.month),
@@ -276,11 +542,13 @@ function renderChartOn(id, data) {
});
}
function drawerField(icon, label, name, value, multiline = false) {
function drawerField(icon, label, name, value, multiline = false, customControl = null) {
const initialValue = text(value);
const control = multiline
? `<textarea name="${name}" rows="2" class="drawer-value drawer-textarea" data-original="${initialValue}">${initialValue}</textarea>`
: `<input name="${name}" value="${initialValue}" class="drawer-value" data-original="${initialValue}">`;
const control = customControl
? customControl
: multiline
? `<textarea name="${name}" rows="2" class="drawer-value drawer-textarea" data-original="${initialValue}">${initialValue}</textarea>`
: `<input name="${name}" value="${initialValue}" class="drawer-value" data-original="${initialValue}">`;
return `<div class="drawer-field">
<div class="drawer-field-label"><i data-lucide="${icon}"></i><span>${label}</span></div>
<div class="drawer-field-control">${control}</div>
@@ -292,28 +560,31 @@ function openDrawer(resource, id) {
const item = list.find((x) => x.id === id);
const drawer = document.querySelector("#drawer");
const fields = resource === "sales"
? [["target_customer","目标客户"],["priority","优先级"],["status","状态"]]
? [["target_customer","业务机会"],["priority","优先级"],["status","状态"]]
: resource === "operations"
? [["project_name","项目名称"],["project_version","项目版本"],["project_status","项目状态"],["current_stage","当前阶段"],["target_customer","目标客户"],["customer_need","客户需求"],["expected_contract_amount","预计签约金额"],["expected_sign_date","预计签约时间"],["sign_probability","签约概率"],["sop_stage","SOP 阶段"],["execution_progress","执行进度"],["current_deliverable","当前交付物"],["risks","风险与阻塞"],["next_action","下一步动作"]]
? [["project_name","项目名称"],["owner","负责人"],["expected_sign_date","截止时间"],["expected_contract_amount","金额"],["notes","项目说明"]]
: resource === "proposals"
? [["customer_or_project_name","客户/项目"],["version","版本号"],["description","版本说明"],["created_date","创建日期"],["status","状态"]]
: [["product_name","产品名称"],["version","版本号"],["version_goal","版本目标"],["feature_list","核心功能清单"],["launch_date","上线日期"],["status","当前状态"],["notes","备注"]];
: [["product_name","产品名称"],["version","版本号"],["version_goal","版本目标"],["feature_list","核心功能清单"],["platform","平台"],["launch_date","上线日期"],["status","当前状态"],["notes","备注"]];
const fieldIcons = {
target_customer: "user", priority: "flag", status: "circle-dot",
project_name: "briefcase-business", project_version: "git-branch", project_status: "circle-dot", current_stage: "map-pin",
customer_need: "file-text", expected_contract_amount: "banknote", expected_sign_date: "calendar",
owner: "user", customer_need: "file-text", expected_contract_amount: "banknote", expected_sign_date: "calendar",
sign_probability: "percent", sop_stage: "list-checks", execution_progress: "activity",
current_deliverable: "package", risks: "alert-triangle", next_action: "arrow-right",
product_name: "box", version: "tag", version_goal: "target", feature_list: "list",
product_name: "box", version: "tag", version_goal: "target", feature_list: "list", platform: "layers",
launch_date: "calendar", notes: "sticky-note"
};
const multilineFields = ["customer_need", "current_deliverable", "risks", "next_action", "version_goal", "feature_list", "notes"];
const followupTarget = resource === "sales" ? "sales" : resource === "proposals" ? "proposal" : resource === "operations" ? "operation" : resource === "products" ? "product" : "";
const title = item.target_customer || item.project_name || item.customer_or_project_name || item.product_name;
drawer.innerHTML = `<div class="drawer-panel"><div class="sticky top-0 z-10 flex items-center justify-between border-b border-slate-200 bg-white/95 px-5 py-3 backdrop-blur"><div><p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">Detail Drawer</p><div class="flex items-center gap-2"><h2 class="drawer-title text-[17px] font-semibold leading-6 text-slate-900">${title}</h2><span id="drawerSaveStatus" class="save-status"></span></div></div><button class="btn btn-ghost btn-sm" onclick="closeDrawer()">关闭</button></div><div class="grid gap-5 p-5">
drawer.innerHTML = `<div class="drawer-panel"><div class="sticky top-0 z-10 flex items-center justify-between border-b border-slate-200 bg-white/95 px-5 py-3 backdrop-blur"><div><p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">Detail Drawer</p><div class="flex items-center gap-2"><h2 class="drawer-title text-[17px] font-semibold leading-6 text-slate-900">${title}</h2><span id="drawerSaveStatus" class="save-status"></span></div></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" onclick="deleteDrawerItem('${resource}', ${id})"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm" onclick="closeDrawer()">关闭</button></div></div><div class="grid gap-5 p-5">
<section>
<h3 class="drawer-section-title">属性</h3>
<form id="drawerForm" class="drawer-fields">${fields.map(([key,label]) => drawerField(fieldIcons[key] || "circle", label, key, item[key], multilineFields.includes(key))).join("")}</form>
<form id="drawerForm" class="drawer-fields">
${drawerField("map-pin", "当前阶段", "current_stage", "", false, `<select name="current_stage" class="drawer-value" onchange="saveDrawerField(this,'${resource}',${id})">${["商务洽谈","系统上线","团队分工","项目交付","上线推广","结项验收"].map((s) => `<option ${s === item.current_stage ? "selected" : ""}>${s}</option>`).join("")}</select>`)}
${fields.map(([key,label]) => drawerField(fieldIcons[key] || "circle", label, key, item[key], multilineFields.includes(key))).join("")}
</form>
</section>
${resource === "proposals" ? `<section><h3 class="drawer-section-title">方案文件</h3><div class="grid gap-2">${["方案","成本","SOP","财务流程"].map((cat) => fileGroup("proposal", item.id, item.version, cat, item.files.filter((f) => f.file_category === cat))).join("")}</div></section>` : ""}
${followupTarget ? `<section>
@@ -380,7 +651,7 @@ function bindDrawerAutosave(resource, id, item) {
field.addEventListener("keydown", (event) => {
if (event.key === "Enter" && field.tagName !== "TEXTAREA") field.blur();
});
field.addEventListener("blur", async () => {
const doSave = async () => {
const value = field.value;
if (value === field.dataset.original) return;
const previous = field.dataset.original;
@@ -400,13 +671,88 @@ function bindDrawerAutosave(resource, id, item) {
setDrawerSaveStatus("保存失败", "danger");
alert(`自动保存失败:${error.message}`);
}
});
};
field.addEventListener("blur", doSave);
if (field.tagName === "SELECT") field.addEventListener("change", doSave);
});
}
window.openDrawer = openDrawer;
window.deleteDrawerItem = async (resource, id) => {
if (!confirm("确认删除?此操作不可撤销。")) return;
try {
await api(`/api/${resource}/${id}`, { method: "DELETE" });
closeDrawer();
await load();
} catch (error) {
alert("删除失败:" + error.message);
}
};
window.toggleTaskDone = async (taskId, projectId) => {
const task = (state.data.tasks || []).find((t) => t.id === taskId);
if (!task) return;
const newStatus = task.status === "done" ? "" : "done";
try {
await api(`/api/tasks/${taskId}`, { method: "PUT", body: JSON.stringify({ data: { status: newStatus } }) });
await load();
} catch (error) {
alert("更新失败:" + error.message);
}
};
window.deleteTask = async (projectId) => {
const taskId = document.querySelector(`#task-id-${projectId}`).value;
if (!taskId) return;
if (!confirm("确认删除该任务?此操作不可撤销。")) return;
try {
await api(`/api/tasks/${taskId}`, { method: "DELETE" });
closeTaskDrawer(projectId);
await load();
} catch (error) {
alert("删除失败:" + error.message);
}
};
let dragTaskId = null;
window.handleTaskDragStart = (event, taskId) => {
dragTaskId = taskId;
event.currentTarget.classList.add("dragging");
event.dataTransfer.effectAllowed = "move";
};
window.handleTaskDrop = async (event, projectId, phase) => {
event.preventDefault();
event.currentTarget.classList.remove("drag-over");
const target = event.currentTarget;
if (!dragTaskId) return;
// Find the dragged element and insert after the nearest task
const dragged = document.querySelector(`.task-row[data-id="${dragTaskId}"]`);
if (!dragged) return;
const afterElement = getDragAfterElement(target, event.clientY);
if (afterElement) {
target.insertBefore(dragged, afterElement);
} else {
target.appendChild(dragged);
}
dragged.classList.remove("dragging");
// Update sort_order in DB
const rows = [...target.querySelectorAll(".task-row")];
const updates = rows.map((row, i) => ({ id: parseInt(row.dataset.id), sort_order: i }));
try {
await api(`/api/tasks/batch-sort`, { method: "POST", body: JSON.stringify({ items: updates }) });
} catch (e) { /* non-critical */ }
dragTaskId = null;
};
function getDragAfterElement(container, y) {
const elements = [...container.querySelectorAll(".task-row:not(.dragging)")];
return elements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: child };
}
return closest;
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
window.closeDrawer = () => document.querySelector("#drawer").classList.remove("open");
window.squireInstances = {};
window.squireCmd = (cmd) => {

View File

@@ -489,3 +489,96 @@ td {
color: #64748b;
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-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 name="viewport" content="width=device-width, initial-scale=1">
<title>科普慰心斋OPC 工作台</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="{{ url_for('static', filename='vendor/tailwind.js') }}"></script>
<script>
tailwind.config = {
theme: {
@@ -21,38 +21,46 @@
}
</script>
<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="https://cdn.jsdelivr.net/npm/squire-rte@1/build/squire-raw.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script src="{{ url_for('static', filename='vendor/chart.js') }}" defer></script>
<script src="{{ url_for('static', filename='vendor/squire.js') }}" defer></script>
<script src="{{ url_for('static', filename='vendor/lucide.js') }}" defer></script>
</head>
<body class="min-h-screen bg-slate-50 text-slate-950">
<header class="topbar border-b border-slate-200 bg-white px-8 py-5">
<div>
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager · 单用户 · 单项目</p>
<h1 class="mt-1 text-2xl font-semibold">科普慰心斋OPC 工作台</h1>
<div class="flex items-center gap-3">
<div>
<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>
<select id="tenantSelect" class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 outline-none focus:border-blue-500" onchange="switchTenant(this.value)">
<option value="科普·无界">科普·无界</option>
<option value="科研·无界">科研·无界</option>
<option value="医患·无界">医患·无界</option>
</select>
</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>
<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 data-tab="sales"><i data-lucide="briefcase-business"></i>销售管理</button>
<button data-tab="projects"><i data-lucide="briefcase-business"></i>重点项目</button>
<button data-tab="proposals"><i data-lucide="file-text"></i>业务方案</button>
<button data-tab="operations"><i data-lucide="activity"></i>运营管理</button>
<button data-tab="products"><i data-lucide="package"></i>产品研发</button>
<button data-tab="finance"><i data-lucide="wallet-cards"></i>财务管理</button>
</nav>
<main class="px-8 py-6">
<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="operations" class="panel"></section>
<section id="products" class="panel"></section>
<section id="finance" class="panel"></section>
</main>
<aside id="drawer" class="drawer" aria-hidden="true"></aside>
<div id="taskModal" class="task-modal"></div>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>