v1.0.1-beta: MySQL迁移 + 用户体系 + 经营管理/任务/产品改版
This commit is contained in:
@@ -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,统一表格展示
|
||||
- 新增项目任务追踪:按阶段分组展示里程碑/执行项/负责人/截止日/卡点
|
||||
|
||||
@@ -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 (?,?,?,?,?,?,?,?,?)""",
|
||||
@@ -348,12 +528,20 @@ def monthly_finance(conn, tenant="科普·无界"):
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
if "user_id" not in session:
|
||||
return redirect("/login")
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/api/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()
|
||||
try:
|
||||
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),
|
||||
"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:
|
||||
conn.close()
|
||||
|
||||
|
||||
TABLES = {
|
||||
"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"]),
|
||||
"products": ("product_versions", ["product_name", "version", "version_goal", "feature_list", "launch_date", "status", "platform", "notes", "tenant"]),
|
||||
"finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes", "tenant"]),
|
||||
"tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "tenant"]),
|
||||
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "rev_2026_06", "rev_2026_07", "rev_2026_08", "rev_2026_09", "gross_2026_06", "gross_2026_07", "gross_2026_08", "gross_2026_09"]),
|
||||
"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
|
||||
@@ -444,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:
|
||||
@@ -452,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
|
||||
@@ -459,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],
|
||||
)
|
||||
@@ -476,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,
|
||||
@@ -493,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()
|
||||
@@ -502,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
|
||||
@@ -515,12 +708,13 @@ def delete_followup(followup_id):
|
||||
|
||||
|
||||
@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:
|
||||
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()
|
||||
return jsonify({"ok": True})
|
||||
finally:
|
||||
@@ -528,6 +722,7 @@ def batch_sort_tasks():
|
||||
|
||||
|
||||
@app.route("/api/files/upload", methods=["POST"])
|
||||
@login_required
|
||||
def upload_file():
|
||||
file = request.files["file"]
|
||||
module = request.form["module"]
|
||||
@@ -563,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:
|
||||
@@ -573,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:
|
||||
|
||||
1053
static/app.js
1053
static/app.js
File diff suppressed because it is too large
Load Diff
@@ -61,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;
|
||||
@@ -530,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;
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
<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">
|
||||
@@ -51,11 +56,6 @@
|
||||
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager</p>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<h1 class="text-2xl font-semibold" id="workspaceTitle">科普 OPC 工作台</h1>
|
||||
<select id="tenantSelect" class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 outline-none focus:border-blue-500" onchange="switchTenant(this.value)">
|
||||
<option value="科普·无界">科普·无界</option>
|
||||
<option value="科研·无界">科研·无界</option>
|
||||
<option value="医患·无界">医患·无界</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,10 +63,10 @@
|
||||
|
||||
<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">
|
||||
@@ -80,6 +80,47 @@
|
||||
</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
45
templates/login.html
Normal 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>
|
||||
Reference in New Issue
Block a user