Compare commits

..

5 Commits

6 changed files with 1813 additions and 347 deletions

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

45
templates/login.html Normal file
View File

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