Compare commits

..

35 Commits

Author SHA1 Message Date
mac
5b1dc4555f v1.0.1-beta: MySQL迁移 + 用户体系 + 经营管理/任务/产品改版 2026-06-22 19:34:31 +08:00
mac
353f11663c v3.3.0 — 左侧增加工作台切换侧边栏(科普/科研/医患) 2026-06-17 15:41:02 +08:00
mac
f3cf6902dd v3.2.1 — 去掉右上角刷新按钮 2026-06-17 15:39:27 +08:00
mac
25f3b9fe0d v3.2.0 — 财务项目增加总确收/总毛利列+弹窗计算显示 2026-06-17 15:36:41 +08:00
mac
cf08b2d241 v3.1.5 — 移除财务页面内嵌的旧表单,仅保留弹窗 2026-06-17 15:29:27 +08:00
mac
4911f24d40 v3.1.4 — 修复pfs变量丢失导致加载失败 2026-06-17 13:13:27 +08:00
mac
8c24abd53e v3.1.3 — 财务弹窗UI优化:分区卡片布局+月网格+圆角+更大尺寸 2026-06-17 13:12:39 +08:00
mac
0a7f70757d v3.1.2 — 财务弹窗按工作台显示项目和业务类型 2026-06-17 13:11:06 +08:00
mac
29dc7e040e v3.1.1 — 科普·无界新增"全品类科普"类型 2026-06-17 13:07:36 +08:00
mac
c8387011cc v3.1.0 — 财务分类重构:三大工作台13种业务类型+66条重新分配 2026-06-17 13:05:58 +08:00
mac
5061de70f8 v3.0.4 — 点击项目行弹出编辑弹窗+PUT保存 2026-06-17 11:27:29 +08:00
mac
ea3ba25da5 v3.0.3 — 修复:新增财务项目改为弹窗按钮+finFilter+createFinance修正 2026-06-17 11:17:12 +08:00
mac
bd7125fab8 v3.0.2 — 新增财务项目改为弹窗按钮+月度趋势缩减为6个月 2026-06-16 17:11:02 +08:00
mac
94dd1fe677 v3.0.1 — 财务项目明细已签/待签分Tab展示 2026-06-16 16:57:13 +08:00
mac
fa6c9b1711 v3.0 — 财务重构为项目财务视图:汇总卡片+项目明细+月度确收/毛利 2026-06-16 16:43:44 +08:00
mac
f4eacfafe2 v2.0.9 — 业务方案删除按钮改为通用deleteDrawerItem支持所有资源 2026-06-16 16:03:33 +08:00
mac
f8c816dc38 v2.0.8 — 任务行添加拖拽手柄图标(grip-vertical) 2026-06-16 16:01:54 +08:00
mac
e2d9049e45 v2.0.7 — 标题随项目切换动态变化:科普/科研/医患 OPC 工作台 2026-06-16 15:57:18 +08:00
mac
1b0049e342 v2.0.6 — X轴12月(前9+当前+后2) + Y轴万元 + 净利口径=确认收入-人力-费用-采购 2026-06-16 15:56:21 +08:00
mac
87a5d4f81d v2.0.5 — 财务曲线改为5类对应+月度净利=确认收入-人力成本-费用-外部采购 2026-06-16 15:54:24 +08:00
mac
d6ec7b24ec v2.0.4 — 曲线图默认折叠可展开,明细列表默认展示 2026-06-16 15:51:09 +08:00
mac
194c91cf25 v2.0.3 — 财务增加费用说明输入框 + 明细表默认折叠可展开 2026-06-16 15:49:35 +08:00
mac
68797e4fb5 v2.0.2 — 财务类型改为5类:签单/确认收入/人力成本/费用/外部采购 2026-06-16 15:47:44 +08:00
mac
af4ae1cbc3 v2.0.1 — 财务表单简化:日月合并+去类型+分类含签单+日历控件 2026-06-16 15:47:04 +08:00
mac
c42abb05da v2.0 — 多项目支持:右上角下拉切换科普/科研/医患三个项目 2026-06-16 15:42:28 +08:00
mac
4d1dc3b355 v1.8.1 — 新增项目表单默认展开 + 去掉按钮 + 按钮文字改为新增项目 2026-06-16 15:15:52 +08:00
mac
60bae583b2 v1.8.0 — 任务checkbox+删除线 + 拖拽排序 + 抽屉删除按钮 2026-06-16 15:14:31 +08:00
mac
c68fcaadcc v1.7.10 — 首页指标简化为6卡片:重点项目/业务方案/产品版本/本月确收/本月毛利/本月净利 2026-06-16 15:09:49 +08:00
mac
2c199aae76 v1.7.9 — 修复首页丢失 m 变量定义 2026-06-16 15:05:11 +08:00
mac
be6a7f5c38 v1.7.8 — 首页改为3表格卡片(合同/确收/毛利) + 合同时间维指标 2026-06-16 14:21:57 +08:00
mac
6adc00a8a7 v1.7.7 — 更新产品文档 PRD v2.0 + CHANGELOG v1.0→v2.0 2026-06-16 14:18:32 +08:00
mac
c4aacd5096 v1.7.6 — 项目抽屉增加红色删除按钮+确认对话框 2026-06-16 14:16:32 +08:00
mac
4f7db75a7f v1.7.5 — 任务页工具栏与卡片对齐 2026-06-16 11:26:17 +08:00
mac
9707009895 v1.7.4 — 项目抽屉「当前阶段」改为6阶段下拉 + select自动保存 2026-06-16 11:25:18 +08:00
mac
6defe95a3f v1.7.3 — 项目抽屉简化为6字段:名称/负责人/阶段/截止/金额/说明 2026-06-16 11:22:47 +08:00
9 changed files with 2514 additions and 353 deletions

View File

@@ -1,5 +1,14 @@
# OPC Manager Version Log
## v1.0.1-beta — 2026-06-22
- 数据库迁移SQLite → MySQL 9.6,适配占位符/类型/游标
- 用户体系:管理员 + OPC负责人角色工作台权限隔离登录鉴权
- 经营管理:状态改为已签约/流程中/待签约三类,签约月份列可编辑,财务指标卡片(确收/毛利/回款/费用/现金流)
- 重点工作与台账:阶段排序固定化+折叠分组,任务拖拽手柄与状态互换位置,关闭抽屉自动刷新,首次进入自动选第一个项目
- 产品迭代统一表格去平台tab状态点击循环切换日期改为 date 选择器,新增版本改用右侧抽屉
- 左侧工作台/顶部 tab 记忆恢复,跨工作台转移功能
- 近期动态修复 tenant 归属
## v1.2.0 — 2026-06-15
- 业务机会 + 运营管理合并为「重点项目」Tab统一表格展示
- 新增项目任务追踪:按阶段分组展示里程碑/执行项/负责人/截止日/卡点

View File

@@ -2,9 +2,11 @@ from datetime import date, datetime
from pathlib import Path
import os
import shutil
import sqlite3
import sqlite3 # 保留用于数据迁移
import mysql.connector
from flask import Flask, jsonify, render_template, request, send_file
from flask import Flask, jsonify, render_template, request, send_file, session, redirect
from werkzeug.security import generate_password_hash, check_password_hash
ROOT = Path(__file__).resolve().parents[1]
@@ -21,145 +23,323 @@ app = Flask(
template_folder=str(ROOT / "templates"),
static_folder=str(ROOT / "static"),
)
app.secret_key = os.environ.get("SECRET_KEY", "opc-dev-secret-2026")
# ---------- 鉴权 ----------
def login_required(f):
from functools import wraps
@wraps(f)
def decorated(*args, **kwargs):
if "user_id" not in session:
return jsonify({"error": "未登录"}), 401
return f(*args, **kwargs)
return decorated
@app.route("/login")
def login_page():
return render_template("login.html")
@app.route("/api/auth/login", methods=["POST"])
def auth_login():
data = request.get_json(force=True) or {}
username = data.get("username", "").strip()
password = data.get("password", "")
conn = db()
try:
user = one(conn, "SELECT * FROM users WHERE username=?", (username,))
if not user or not check_password_hash(user["password_hash"], password):
return jsonify({"error": "用户名或密码错误"}), 401
session["user_id"] = user["id"]
session["username"] = user["username"]
session["display_name"] = user["display_name"]
session["role"] = user["role"]
# 管理员可看所有工作台OPC负责人看分配的工作台
if user["role"] == "admin":
session["tenants"] = ["科普·无界", "科研·无界", "医患·无界"]
else:
ut = rows(conn, "SELECT tenant FROM user_tenants WHERE user_id=?", (user["id"],))
session["tenants"] = [x["tenant"] for x in ut]
return jsonify({
"ok": True,
"user": {"id": user["id"], "username": user["username"], "display_name": user["display_name"], "role": user["role"]},
"tenants": session["tenants"],
})
finally:
conn.close()
@app.route("/api/auth/logout", methods=["POST"])
def auth_logout():
session.clear()
return jsonify({"ok": True})
@app.route("/api/auth/me")
def auth_me():
if "user_id" not in session:
return jsonify({"logged_in": False})
return jsonify({
"logged_in": True,
"user": {"id": session["user_id"], "username": session["username"], "display_name": session["display_name"], "role": session["role"]},
"tenants": session.get("tenants", []),
})
# ---------- 业务 API ----------
def db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
return mysql.connector.connect(
host="127.0.0.1",
port=3306,
user="opc",
password="opc123456",
database="opc",
charset="utf8mb4",
collation="utf8mb4_unicode_ci",
)
def now():
return datetime.utcnow().isoformat()
def _exec(conn, sql, args=()):
"""执行 SQL自动将 ? 转为 MySQL 的 %s"""
cur = conn.cursor(dictionary=True)
cur.execute(sql.replace("?", "%s"), args)
return cur
def rows(conn, sql, args=()):
return [dict(row) for row in conn.execute(sql, args).fetchall()]
cur = _exec(conn, sql, args)
rows = cur.fetchall()
cur.close()
return rows
def one(conn, sql, args=()):
row = conn.execute(sql, args).fetchone()
return dict(row) if row else None
cur = _exec(conn, sql, args)
row = cur.fetchone()
cur.close()
return row
def init_db():
conn = db()
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS sales_leads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_customer TEXT NOT NULL,
priority TEXT NOT NULL DEFAULT 'P1',
status TEXT NOT NULL DEFAULT '待跟进',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS follow_up_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_type TEXT NOT NULL,
target_id INTEGER NOT NULL,
followed_at TEXT NOT NULL DEFAULT '',
follower TEXT NOT NULL DEFAULT '慰心',
follow_up_method TEXT NOT NULL DEFAULT '记录',
content TEXT NOT NULL DEFAULT '',
next_action TEXT NOT NULL DEFAULT '',
next_follow_up_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS business_proposals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_or_project_name TEXT NOT NULL,
version TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '草稿',
created_date TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS operation_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_name TEXT NOT NULL,
project_version TEXT NOT NULL DEFAULT 'v1.0',
project_type TEXT NOT NULL DEFAULT 'opportunity',
project_status TEXT NOT NULL DEFAULT '',
current_stage TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL DEFAULT '慰心',
start_date TEXT NOT NULL DEFAULT '',
end_date TEXT NOT NULL DEFAULT '',
target_customer TEXT NOT NULL DEFAULT '',
customer_need TEXT NOT NULL DEFAULT '',
expected_contract_amount REAL NOT NULL DEFAULT 0,
expected_sign_date TEXT NOT NULL DEFAULT '',
sign_probability REAL NOT NULL DEFAULT 0,
next_action TEXT NOT NULL DEFAULT '',
_exec(conn, """CREATE TABLE IF NOT EXISTS sales_leads (
id INT AUTO_INCREMENT PRIMARY KEY,
target_customer VARCHAR(1000) NOT NULL,
priority VARCHAR(1000) NOT NULL DEFAULT 'P1',
status VARCHAR(1000) NOT NULL DEFAULT '待跟进',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""")
conn.commit()
_exec(conn, """CREATE TABLE IF NOT EXISTS follow_up_records (
id INT AUTO_INCREMENT PRIMARY KEY,
target_type VARCHAR(1000) NOT NULL,
target_id INT NOT NULL,
followed_at VARCHAR(1000) NOT NULL DEFAULT '',
follower VARCHAR(1000) NOT NULL DEFAULT '慰心',
follow_up_method VARCHAR(1000) NOT NULL DEFAULT '记录',
content VARCHAR(1000) NOT NULL DEFAULT '',
next_action VARCHAR(1000) NOT NULL DEFAULT '',
next_follow_up_at VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""")
conn.commit()
_exec(conn, """CREATE TABLE IF NOT EXISTS business_proposals (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_or_project_name VARCHAR(1000) NOT NULL,
version VARCHAR(1000) NOT NULL,
description VARCHAR(1000) NOT NULL DEFAULT '',
status VARCHAR(1000) NOT NULL DEFAULT '草稿',
created_date VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""")
conn.commit()
_exec(conn, """CREATE TABLE IF NOT EXISTS operation_projects (
id INT AUTO_INCREMENT PRIMARY KEY,
project_name VARCHAR(1000) NOT NULL,
project_version VARCHAR(1000) NOT NULL DEFAULT 'v1.0',
project_type VARCHAR(1000) NOT NULL DEFAULT 'opportunity',
project_status VARCHAR(1000) NOT NULL DEFAULT '',
current_stage VARCHAR(1000) NOT NULL DEFAULT '',
owner VARCHAR(1000) NOT NULL DEFAULT '慰心',
start_date VARCHAR(1000) NOT NULL DEFAULT '',
end_date VARCHAR(1000) NOT NULL DEFAULT '',
target_customer VARCHAR(1000) NOT NULL DEFAULT '',
customer_need VARCHAR(1000) NOT NULL DEFAULT '',
expected_contract_amount DOUBLE NOT NULL DEFAULT 0,
expected_sign_date VARCHAR(1000) NOT NULL DEFAULT '',
sign_probability DOUBLE NOT NULL DEFAULT 0,
next_action VARCHAR(1000) NOT NULL DEFAULT '',
related_business_proposal_id INTEGER,
sop_file_id INTEGER,
sop_stage TEXT NOT NULL DEFAULT '',
execution_progress REAL NOT NULL DEFAULT 0,
current_deliverable TEXT NOT NULL DEFAULT '',
risks TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS product_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_name TEXT NOT NULL,
version TEXT NOT NULL,
version_goal TEXT NOT NULL DEFAULT '',
feature_list TEXT NOT NULL DEFAULT '',
launch_date TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '规划中',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS finance_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
month TEXT NOT NULL,
project_name TEXT NOT NULL DEFAULT '科普(慰心斋)',
record_type TEXT NOT NULL,
category TEXT NOT NULL DEFAULT '',
amount REAL NOT NULL DEFAULT 0,
occurred_date TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS file_assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module TEXT NOT NULL,
owner_id INTEGER NOT NULL,
owner_version TEXT NOT NULL DEFAULT '',
file_category TEXT NOT NULL DEFAULT '',
file_name TEXT NOT NULL,
file_type TEXT NOT NULL DEFAULT '',
sop_stage VARCHAR(1000) NOT NULL DEFAULT '',
execution_progress DOUBLE NOT NULL DEFAULT 0,
current_deliverable VARCHAR(1000) NOT NULL DEFAULT '',
risks VARCHAR(1000) NOT NULL DEFAULT '',
notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""")
conn.commit()
_exec(conn, """CREATE TABLE IF NOT EXISTS product_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
product_name VARCHAR(1000) NOT NULL,
version VARCHAR(1000) NOT NULL,
version_goal VARCHAR(1000) NOT NULL DEFAULT '',
feature_list VARCHAR(1000) NOT NULL DEFAULT '',
launch_date VARCHAR(1000) NOT NULL DEFAULT '',
status VARCHAR(1000) NOT NULL DEFAULT '规划中',
notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""")
conn.commit()
_exec(conn, """CREATE TABLE IF NOT EXISTS finance_records (
id INT AUTO_INCREMENT PRIMARY KEY,
month VARCHAR(1000) NOT NULL,
project_name VARCHAR(1000) NOT NULL DEFAULT '科普(慰心斋)',
record_type VARCHAR(1000) NOT NULL,
category VARCHAR(1000) NOT NULL DEFAULT '',
amount DOUBLE NOT NULL DEFAULT 0,
occurred_date VARCHAR(1000) NOT NULL DEFAULT '',
notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""")
conn.commit()
_exec(conn, """CREATE TABLE IF NOT EXISTS file_assets (
id INT AUTO_INCREMENT PRIMARY KEY,
module VARCHAR(1000) NOT NULL,
owner_id INT NOT NULL,
owner_version VARCHAR(1000) NOT NULL DEFAULT '',
file_category VARCHAR(1000) NOT NULL DEFAULT '',
file_name VARCHAR(1000) NOT NULL,
file_type VARCHAR(1000) NOT NULL DEFAULT '',
file_size INTEGER NOT NULL DEFAULT 0,
file_path TEXT NOT NULL,
file_path VARCHAR(1000) NOT NULL,
is_external INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS project_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""")
conn.commit()
_exec(conn, """CREATE TABLE IF NOT EXISTS project_tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INTEGER NOT NULL,
phase 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 ''")
phase VARCHAR(1000) NOT NULL DEFAULT '',
milestone VARCHAR(1000) NOT NULL DEFAULT '',
task VARCHAR(1000) NOT NULL DEFAULT '',
owner VARCHAR(1000) NOT NULL DEFAULT '',
due_date VARCHAR(1000) NOT NULL DEFAULT '',
blockers VARCHAR(1000) NOT NULL DEFAULT '',
notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""")
conn.commit()
# 用户表
try: _exec(conn, """CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'opc_owner',
created_at VARCHAR(30) NOT NULL DEFAULT ''
)""")
except: pass
conn.commit()
# 用户-工作台关联表
try: _exec(conn, """CREATE TABLE IF NOT EXISTS user_tenants (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
tenant VARCHAR(100) NOT NULL,
UNIQUE KEY (user_id, tenant)
)""")
except: pass
conn.commit()
# project_finances 表(月度预算 + 签约信息)
try: _exec(conn, """CREATE TABLE IF NOT EXISTS project_finances (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界',
project_id VARCHAR(100) NOT NULL DEFAULT '',
business_type VARCHAR(100) NOT NULL DEFAULT '',
customer_name VARCHAR(200) NOT NULL DEFAULT '',
sign_amount DOUBLE NOT NULL DEFAULT 0,
sign_month VARCHAR(20) NOT NULL DEFAULT '',
status VARCHAR(50) NOT NULL DEFAULT '待签约',
sales_person VARCHAR(100) NOT NULL DEFAULT '',
total_rev DOUBLE NOT NULL DEFAULT 0,
total_gross DOUBLE NOT NULL DEFAULT 0,
budget_data TEXT,
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""")
except: pass
conn.commit()
# Schema migrations — 添加后续迁移的列(幂等)
migrations = [
"ALTER TABLE sales_leads ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE follow_up_records ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE business_proposals ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE business_proposals ADD COLUMN proposal_type VARCHAR(100) NOT NULL DEFAULT '业务方案'",
"ALTER TABLE business_proposals ADD COLUMN notes VARCHAR(2000) NOT NULL DEFAULT ''",
"ALTER TABLE operation_projects ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE product_versions ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE product_versions ADD COLUMN platform VARCHAR(100) NOT NULL DEFAULT ''",
"ALTER TABLE finance_records ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE project_tasks ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE project_tasks ADD COLUMN status VARCHAR(50) NOT NULL DEFAULT '未开始'",
"ALTER TABLE project_tasks ADD COLUMN sort_order INT NOT NULL DEFAULT 0",
"ALTER TABLE project_tasks ADD COLUMN priority VARCHAR(10) NOT NULL DEFAULT 'P2'",
# 12 月字段(确收/毛利/回款/费用/月度现金流)
]
for m in ["01","02","03","04","05","06","07","08","09","10","11","12"]:
migrations.append(f"ALTER TABLE project_finances ADD COLUMN rev_2026_{m} DOUBLE NOT NULL DEFAULT 0")
migrations.append(f"ALTER TABLE project_finances ADD COLUMN gross_2026_{m} DOUBLE NOT NULL DEFAULT 0")
migrations.append(f"ALTER TABLE project_finances ADD COLUMN payment_2026_{m} DOUBLE NOT NULL DEFAULT 0")
migrations.append(f"ALTER TABLE project_finances ADD COLUMN cost_2026_{m} DOUBLE NOT NULL DEFAULT 0")
for mig in migrations:
try: _exec(conn, mig)
except: pass
conn.commit()
# 初始化默认用户(只执行一次)
if not one(conn, "SELECT id FROM users LIMIT 1"):
_exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""",
("qiukai", generate_password_hash("yxcowork2026", "pbkdf2:sha256"), "qiukai", "admin", date.today().isoformat()))
_exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""",
("kepu", generate_password_hash("kepu123", "pbkdf2:sha256"), "科普负责人", "opc_owner", date.today().isoformat()))
_exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""",
("keyan", generate_password_hash("keyan123", "pbkdf2:sha256"), "科研负责人", "opc_owner", date.today().isoformat()))
_exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""",
("yihuan", generate_password_hash("yihuan123", "pbkdf2:sha256"), "医患负责人", "opc_owner", date.today().isoformat()))
# 各 OPC 负责人绑定工作台
for uname, tenant in [("kepu","科普·无界"),("keyan","科研·无界"),("yihuan","医患·无界")]:
u = one(conn, "SELECT id FROM users WHERE username=?", (uname,))
if u:
_exec(conn, "INSERT INTO user_tenants (user_id, tenant) VALUES (?,?)", (u["id"], tenant))
conn.commit()
if one(conn, "SELECT id FROM sales_leads LIMIT 1"):
conn.close()
@@ -173,16 +353,16 @@ CREATE TABLE IF NOT EXISTS project_tasks (
("天广实生物", "P1", "待跟进", "血液肿瘤医生教育机会。"),
]
for customer, priority, status, note in sales:
cur = conn.execute(
cur = _exec(conn,
"INSERT INTO sales_leads (target_customer, priority, status) VALUES (?,?,?)",
(customer, priority, status),
)
conn.execute(
_exec(conn,
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("sales", cur.lastrowid, date.today().isoformat(), note, "明确下一次沟通人和时间"),
)
cur = conn.execute(
cur = _exec(conn,
"INSERT INTO business_proposals (customer_or_project_name,version,description,status,created_date) VALUES (?,?,?,?,?)",
("信达生物", "v1.5", "信达科普项目续约与报价方案", "已提交客户", "2026-05-28"),
)
@@ -204,14 +384,14 @@ CREATE TABLE IF NOT EXISTS project_tasks (
]
op_dir = WEIXIN_BASE / "3、运营方案"
for name, version, kind, status, stage, progress, note in projects:
cur = conn.execute(
cur = _exec(conn,
"""INSERT INTO operation_projects
(project_name,project_version,project_type,project_status,current_stage,target_customer,customer_need,
expected_contract_amount,expected_sign_date,sign_probability,next_action,sop_stage,execution_progress,current_deliverable)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(name, version, kind, status, stage, "圆心科技", "科普内容项目执行与管理", 0 if kind == "execution" else 200, "2026-06", 100 if kind == "execution" else 70, "补齐版本要求文件并更新下一节点", stage, progress, note),
)
conn.execute(
_exec(conn,
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("operation", cur.lastrowid, date.today().isoformat(), note, "补齐版本要求文件并更新下一节点"),
)
@@ -234,11 +414,11 @@ CREATE TABLE IF NOT EXISTS project_tasks (
("渠道分发引擎", "v1.0", "六渠道统一分发", "分发 API、内容适配、分发排期、效果追踪", "2027-Q1", "规划中", "科普平台"),
]
for product in products:
cur = conn.execute(
cur = _exec(conn,
"INSERT INTO product_versions (product_name,version,version_goal,feature_list,launch_date,status,platform) VALUES (?,?,?,?,?,?,?)",
product,
)
conn.execute(
_exec(conn,
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("product", cur.lastrowid, date.today().isoformat(), f"{product[0]} {product[1]}{product[2]}", "按路线图推进"),
)
@@ -250,7 +430,7 @@ CREATE TABLE IF NOT EXISTS project_tasks (
("2026-05", "cost_expense", "运营管理", 16, "项目管理与渠道协同"),
("2026-06", "cost_expense", "渠道分发", 24, "投放与分发费用"),
]:
conn.execute(
_exec(conn,
"INSERT INTO finance_records (month,record_type,category,amount,occurred_date,notes) VALUES (?,?,?,?,?,?)",
(month, record_type, category, amount, f"{month}-01", notes),
)
@@ -269,7 +449,7 @@ CREATE TABLE IF NOT EXISTS project_tasks (
("阶段2系统与标准搭建", "脚本生产及审核", "生产脚本", "军营", "2026-06-12", "脚本目前生产比较机械,需要提前准备", "细分标签领域完成"),
]
for phase, milestone, task, owner, due_date, blockers, notes in tasks_seed:
conn.execute(
_exec(conn,
"INSERT INTO project_tasks (project_id,phase,milestone,task,owner,due_date,blockers,notes) VALUES (?,?,?,?,?,?,?,?)",
(1, phase, milestone, task, owner, due_date, blockers, notes),
)
@@ -282,7 +462,7 @@ def add_file_index(conn, module, owner_id, owner_version, category, path, extern
path = Path(path)
if not path.exists():
return
conn.execute(
_exec(conn,
"""INSERT INTO file_assets
(module,owner_id,owner_version,file_category,file_name,file_type,file_size,file_path,is_external)
VALUES (?,?,?,?,?,?,?,?,?)""",
@@ -316,44 +496,86 @@ 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
@app.route("/")
def index():
if "user_id" not in session:
return redirect("/login")
return render_template("index.html")
@app.route("/api/bootstrap")
def bootstrap():
if "user_id" not in session:
return jsonify({"error": "未登录"}), 401
tenant = request.args.get("tenant", session.get("tenants", ["科普·无界"])[0])
# 验证用户是否有权限访问该 workbench
allowed = session.get("tenants", [])
if tenant not in allowed:
tenant = allowed[0]
conn = db()
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")
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
def sum_finance(months, rtype):
return sum(x["amount"] for x in finance if x["month"] in months and x["record_type"] == rtype)
months_2026 = [f"2026-{m:02d}" for m in range(1,7)]
months_q2 = ["2026-04","2026-05","2026-06"]
revenue_annual = sum_finance(months_2026, "revenue")
cost_annual = sum_finance(months_2026, "cost_expense")
revenue_q2 = sum_finance(months_q2, "revenue")
cost_q2 = sum_finance(months_q2, "cost_expense")
revenue_month = sum_finance([current_month], "revenue")
cost_month = sum_finance([current_month], "cost_expense")
# Contract aggregates
# 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 = {
@@ -363,37 +585,46 @@ 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_month,
"monthly_net_profit": revenue_month - cost_month,
"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": revenue_annual,
"revenue_q2": revenue_q2,
"gross_annual": revenue_annual - cost_annual,
"gross_q2": revenue_q2 - cost_q2,
"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), "tasks": rows(conn, "SELECT * FROM project_tasks ORDER BY phase, id")})
return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "projectFinances": pfs, "financeMonthly": monthly_finance(conn, tenant), "tasks": tasks, "tenant": tenant, "tenants": allowed})
finally:
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", "platform", "notes"]),
"finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes"]),
"tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes"]),
"sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]),
"proposals": ("business_proposals", ["customer_or_project_name", "version", "description", "status", "created_date", "proposal_type", "notes", "tenant"]),
"operations": ("operation_projects", ["project_name", "project_version", "project_type", "project_status", "current_stage", "owner", "target_customer", "customer_need", "expected_contract_amount", "expected_sign_date", "sign_probability", "next_action", "sop_stage", "execution_progress", "current_deliverable", "risks", "notes", "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", "priority", "tenant"]),
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "total_rev", "total_gross", "budget_data"]),
}
@app.route("/api/<resource>", methods=["POST"])
@login_required
def create_resource(resource):
if resource not in TABLES:
return jsonify({"error": "unknown resource"}), 404
@@ -402,7 +633,7 @@ def create_resource(resource):
values = [payload.get(col, "") for col in cols]
conn = db()
try:
cur = conn.execute(f"INSERT INTO {table} ({','.join(cols)}) VALUES ({','.join(['?'] * len(cols))})", values)
cur = _exec(conn, f"INSERT INTO {table} ({','.join(cols)}) VALUES ({','.join(['?'] * len(cols))})", values)
conn.commit()
return jsonify({"id": cur.lastrowid})
finally:
@@ -410,6 +641,7 @@ def create_resource(resource):
@app.route("/api/<resource>/<int:item_id>", methods=["PUT", "DELETE"])
@login_required
def update_resource(resource, item_id):
if resource not in TABLES:
return jsonify({"error": "unknown resource"}), 404
@@ -417,13 +649,13 @@ def update_resource(resource, item_id):
conn = db()
try:
if request.method == "DELETE":
conn.execute(f"DELETE FROM {table} WHERE id=?", (item_id,))
_exec(conn, f"DELETE FROM {table} WHERE id=?", (item_id,))
conn.commit()
return jsonify({"ok": True})
payload = request.get_json(force=True).get("data", {})
update_cols = [col for col in cols if col in payload]
if update_cols:
conn.execute(
_exec(conn,
f"UPDATE {table} SET {','.join([col + '=?' for col in update_cols])}, updated_at=? WHERE id=?",
[payload[col] for col in update_cols] + [now(), item_id],
)
@@ -434,14 +666,15 @@ def update_resource(resource, item_id):
@app.route("/api/followups/<target_type>/<int:target_id>", methods=["POST"])
@login_required
def add_followup(target_type, target_id):
payload = request.get_json(force=True).get("data", {})
conn = db()
try:
conn.execute(
_exec(conn,
"""INSERT INTO follow_up_records
(target_type,target_id,followed_at,follower,follow_up_method,content,next_action,next_follow_up_at)
VALUES (?,?,?,?,?,?,?,?)""",
(target_type,target_id,followed_at,follower,follow_up_method,content,next_action,next_follow_up_at,tenant)
VALUES (?,?,?,?,?,?,?,?,?)""",
(
target_type,
target_id,
@@ -451,6 +684,7 @@ def add_followup(target_type, target_id):
payload.get("content") or "",
payload.get("next_action") or "",
payload.get("next_follow_up_at") or "",
payload.get("tenant") or "科普·无界",
),
)
conn.commit()
@@ -460,10 +694,11 @@ def add_followup(target_type, target_id):
@app.route("/api/followups/<int:followup_id>", methods=["DELETE"])
@login_required
def delete_followup(followup_id):
conn = db()
try:
cur = conn.execute("DELETE FROM follow_up_records WHERE id=?", (followup_id,))
cur = _exec(conn, "DELETE FROM follow_up_records WHERE id=?", (followup_id,))
conn.commit()
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
@@ -472,7 +707,22 @@ def delete_followup(followup_id):
conn.close()
@app.route("/api/tasks/batch-sort", methods=["POST"])
@login_required
def batch_sort_tasks():
conn = db()
try:
items = request.get_json(force=True).get("items", [])
for item in items:
_exec(conn, "UPDATE project_tasks SET sort_order=? WHERE id=?", (item["sort_order"], item["id"]))
conn.commit()
return jsonify({"ok": True})
finally:
conn.close()
@app.route("/api/files/upload", methods=["POST"])
@login_required
def upload_file():
file = request.files["file"]
module = request.form["module"]
@@ -508,6 +758,7 @@ def file_content(file_id):
@app.route("/api/files/<int:file_id>", methods=["DELETE"])
@login_required
def delete_file(file_id):
conn = db()
try:
@@ -518,7 +769,7 @@ def delete_file(file_id):
path = Path(asset["file_path"])
if path.exists() and str(UPLOAD_DIR) in str(path.resolve()):
path.unlink(missing_ok=True)
conn.execute("DELETE FROM file_assets WHERE id=?", (file_id,))
_exec(conn, "DELETE FROM file_assets WHERE id=?", (file_id,))
conn.commit()
return jsonify({"ok": True})
finally:

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

45
templates/login.html Normal file
View File

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