diff --git a/VERSION_LOG.md b/VERSION_LOG.md index 90dd98e..497a99c 100644 --- a/VERSION_LOG.md +++ b/VERSION_LOG.md @@ -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,统一表格展示 - 新增项目任务追踪:按阶段分组展示里程碑/执行项/负责人/截止日/卡点 diff --git a/backend/flask_app.py b/backend/flask_app.py index 1fd4997..2ff6770 100644 --- a/backend/flask_app.py +++ b/backend/flask_app.py @@ -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/", 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//", 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//", 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/", 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/", 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: diff --git a/static/app.js b/static/app.js index a6dc804..f9af63f 100644 --- a/static/app.js +++ b/static/app.js @@ -3,15 +3,33 @@ const state = { data: null, tenant: "科普·无界", opFilter: "all", - finFilter: "已签单", - projectView: null, + finFilter: "已签约", + selectedProject: null, + taskQuery: "", chart: null, chart2: null, productPlatform: "all", + uploadTasks: [], }; -const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")} 万`; +const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")} 元`; const text = (value) => value === undefined || value === null || value === "" ? "—" : value; +const escapeHtml = (str) => String(str || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +function monthOptions(selected = '') { + const now = new Date(); + const startYear = now.getFullYear() - 1; + const endYear = now.getFullYear() + 1; + let options = selected ? '' : ''; + for (let y = startYear; y <= endYear; y++) { + for (const m of ["01","02","03","04","05","06","07","08","09","10","11","12"]) { + const val = y + "-" + m; + const sel = val === selected ? " selected" : ""; + options += ``; + } + } + return options; +} async function api(path, options = {}) { const response = await fetch(path, { @@ -23,6 +41,15 @@ async function api(path, options = {}) { return data; } +async function logActivity(targetType, targetId, content) { + try { + await api(`/api/followups/${targetType}/${targetId}`, { + method: "POST", + body: JSON.stringify({ data: { content, tenant: state.tenant } }), + }); + } catch (e) { /* non-critical */ } +} + function badge(value) { const val = String(value || "—"); let cls = "badge-slate"; @@ -56,6 +83,7 @@ async function load() { function switchTab(tab) { state.active = tab; + localStorage.setItem("opc-active-tab", tab); document.querySelectorAll("#tabs button").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === tab)); document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab)); render(); @@ -69,28 +97,6 @@ function render() { renderProducts(); renderFinance(); if (window.lucide) window.lucide.createIcons(); - // Decode and render rich HTML content in followup records - drawer.querySelectorAll(".rich-content").forEach((el) => { - const html = el.dataset.html; - if (html) el.innerHTML = decodeURIComponent(html); - }); - // Initialize Squire editor - const squireDiv = drawer.querySelector(".squire-editor"); - if (squireDiv && window.Squire) { - const id = squireDiv.id; - if (window.squireInstances[id]) window.squireInstances[id].destroy(); - const sq = new Squire(squireDiv, { blockTag: "P" }); - sq.addEventListener("input", () => { - const form = squireDiv.closest("form"); - const btn = form.querySelector(".comment-submit"); - }); - window.squireInstances[id] = sq; - // Handle placeholder - squireDiv.addEventListener("focus", () => squireDiv.classList.add("focused")); - squireDiv.addEventListener("blur", () => { - if (!squireDiv.textContent.trim()) squireDiv.classList.remove("focused"); - }); - } } function renderHome() { @@ -117,14 +123,12 @@ function renderHome() { const tblCard = (title, rows) => card(`

${title}

${rows.map(([label, value]) => ``).join("")}
${label}${value}
`, "p-4"); document.querySelector("#home").innerHTML = `
-
+
${[ - ["重点项目", m.total_projects, "projects"], - ["业务方案", m.total_proposals, "proposals"], - ["产品版本", m.total_products, "products"], - ["本月确收", money(m.monthly_revenue), "finance"], - ["本月毛利", money(m.monthly_gross || m.monthly_net_profit), "finance"], - ["本月净利", money(m.monthly_net_profit), "finance"], + ["经营管理", m.total_projects, "finance"], + ["重点工作与台账", m.total_proposals, "projects"], + ["业务方案", m.total_products, "proposals"], + ["产品迭代", m.upcoming_products, "products"], ].map(([label, value, tab]) => ``).join("")}
${tblCard("合同金额", rows1)}${tblCard("确收金额", rows2)}${tblCard("确收毛利", rows3)}
@@ -132,7 +136,7 @@ function renderHome() { ${card(`

财务趋势

${badge("YYYY-MM")}
`, "p-4")} ${card(`

风险提醒

${(summary.risks.length ? summary.risks : [{ title: "暂无高风险", content: "当前无明确阻塞,按周更新即可。" }]).map((r) => `

${r.title}

${r.content}

`).join("")}
`, "p-5")}
- ${card(`

近期动态

${summary.recent.map((r) => `
${r.content}${r.followed_at}
`).join("")}
`, "p-5")} + ${card(`

近期动态

${summary.recent.map((r) => `
${r.content}
${r.followed_at}
`).join("")}
`, "p-5")}
`; renderChart(financeMonthly); @@ -168,8 +172,14 @@ async function createResource(event, resource) { event.preventDefault(); const form = event.currentTarget; const data = Object.fromEntries(new FormData(form).entries()); + data.tenant = state.tenant; try { - await api(`/api/${resource}`, { method: "POST", body: JSON.stringify({ data }) }); + const result = await api(`/api/${resource}`, { method: "POST", body: JSON.stringify({ data }) }); + // 活动记录 + const targetMap = { sales: "sales", proposals: "proposal", operations: "operation", products: "product" }; + const resType = targetMap[resource] || resource; + const name = data.project_name || data.target_customer || data.customer_or_project_name || data.product_name || ""; + if (result.id && name) logActivity(resType, result.id, "创建了" + name); form.reset(); await load(); } catch (error) { @@ -179,10 +189,99 @@ async function createResource(event, resource) { window.createSales = (event) => createResource(event, "sales"); window.createProposal = (event) => createResource(event, "proposals"); -window.createOperation = (event) => createResource(event, "operations"); -window.createProduct = (event) => createResource(event, "products"); +window.createOperation = async (event) => { + await createResource(event, "operations"); + if (typeof closeNewProjectModal === "function") closeNewProjectModal(); +}; +window.openProductDrawer = () => { + const drawer = document.querySelector("#productDrawer"); + drawer.innerHTML = `
+ 新增产品版本 + +
+
+ + + + + + +
+ + +
+
`; + drawer.classList.add("open"); + if (window.lucide) window.lucide.createIcons(); +}; + +window.closeProductDrawer = () => { + document.querySelector("#productDrawer").classList.remove("open"); +}; + +window.cycleProductStatus = async (id) => { + const products = state.data.products || []; + const product = products.find(x => x.id === id); + if (!product) return; + const statuses = ["规划中", "设计中", "开发中", "测试中", "已上线", "已延期", "已取消"]; + const current = statuses.indexOf(product.status) >= 0 ? product.status : "规划中"; + const newStatus = statuses[(statuses.indexOf(current) + 1) % statuses.length]; + try { + await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { status: newStatus } }) }); + product.status = newStatus; + renderProducts(); + } catch (error) { + alert("更新失败:" + error.message); + } +}; + +window.editProductDate = (event, id) => { + event.stopPropagation(); + const products = state.data.products || []; + const product = products.find(x => x.id === id); + if (!product) return; + const span = event.currentTarget; + const td = span.parentElement; + const currentValue = product.launch_date || ""; + const input = document.createElement("input"); + input.type = "date"; + input.className = "w-full rounded border border-slate-200 px-1 py-1 text-sm"; + input.value = currentValue; + input.addEventListener("change", async () => { + const newValue = input.value; + try { + await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { launch_date: newValue } }) }); + product.launch_date = newValue; + td.innerHTML = `${newValue || '—'}`; + } catch (e) { alert("修改失败:" + e.message); } + }); + input.addEventListener("blur", () => { + td.innerHTML = `${currentValue || '—'}`; + }); + td.innerHTML = ""; + td.appendChild(input); + input.focus(); +}; + +window.submitProductDrawer = async (event) => { + event.preventDefault(); + const form = event.currentTarget; + const data = Object.fromEntries(new FormData(form).entries()); + data.platform = ""; + data.tenant = state.tenant; + try { + const result = await api("/api/products", { method: "POST", body: JSON.stringify({ data }) }); + form.reset(); + closeProductDrawer(); + if (result.id) logActivity("product", result.id, "创建了产品版本「" + data.product_name + " " + data.version + "」"); + await load(); + } catch (error) { + alert("创建失败:" + error.message); + } +}; window.openTaskForm = (projectId, taskId) => { + if (!projectId) return; const drawer = document.querySelector(`#task-drawer-${projectId}`); const titleEl = drawer.querySelector(".task-drawer-title"); if (taskId === null) { @@ -193,6 +292,8 @@ window.openTaskForm = (projectId, taskId) => { document.querySelector(`#task-due-${projectId}`).value = ""; document.querySelector(`#task-notes-${projectId}`).value = ""; document.querySelector(`#task-blockers-${projectId}`).value = ""; + document.querySelector(`#task-priority-${projectId}`).value = "P2"; + document.querySelector(`#task-status-${projectId}`).value = "未开始"; document.querySelector(`#task-submit-btn-${projectId}`).textContent = "确认新增"; if (titleEl) titleEl.textContent = "新增任务"; } else { @@ -205,6 +306,8 @@ window.openTaskForm = (projectId, taskId) => { document.querySelector(`#task-due-${projectId}`).value = task.due_date || ""; document.querySelector(`#task-notes-${projectId}`).value = task.notes || ""; document.querySelector(`#task-blockers-${projectId}`).value = task.blockers || ""; + document.querySelector(`#task-priority-${projectId}`).value = task.priority || "P2"; + document.querySelector(`#task-status-${projectId}`).value = task.status || "未开始"; document.querySelector(`#task-submit-btn-${projectId}`).textContent = "保存修改"; if (titleEl) titleEl.textContent = "编辑任务"; } @@ -212,21 +315,58 @@ window.openTaskForm = (projectId, taskId) => { }; window.closeTaskDrawer = (projectId) => { document.querySelector(`#task-drawer-${projectId}`).classList.remove("open"); + refreshTaskList(projectId); +}; + +window.refreshTaskList = (projectId) => { + const container = document.querySelector(`.task-feed`); + if (!container) return; + container.innerHTML = renderTaskListHTML(projectId); + if (window.lucide) window.lucide.createIcons(); }; window.submitTaskForm = async (event, projectId) => { event.preventDefault(); const data = Object.fromEntries(new FormData(event.currentTarget).entries()); data.project_id = Number(projectId); + data.tenant = state.tenant; const taskId = data.task_id; delete data.task_id; try { if (taskId) { await api(`/api/tasks/${taskId}`, { method: "PUT", body: JSON.stringify({ data }) }); + const task = (state.data.tasks || []).find(t => t.id === parseInt(taskId)); + if (task) Object.assign(task, data); + if (data.task) logActivity("task", taskId, "更新了任务「" + data.task + "」"); + // 局部更新该行,保持滚动位置 + const row = document.querySelector(`.task-item[data-id="${taskId}"]`); + if (row) { + const titleEl = row.querySelector(".task-title"); + const descEl = row.querySelector(".task-desc"); + const ownerEl = row.querySelector(".task-meta"); + const dateEl = row.querySelectorAll(".task-meta")[1]; + if (titleEl) titleEl.textContent = data.task || ""; + if (descEl) { if (data.notes) { descEl.textContent = data.notes; } else { descEl.remove(); } } + if (ownerEl) ownerEl.textContent = data.owner || ""; + if (dateEl) dateEl.textContent = data.due_date || ""; + let blockEl = row.querySelector(".task-blocker"); + if (data.blockers) { + if (!blockEl) { + blockEl = document.createElement("span"); + blockEl.className = "task-blocker"; + row.querySelector(".task-content").appendChild(blockEl); + } + blockEl.textContent = "\u26a0 " + data.blockers; + } else if (blockEl) { + blockEl.remove(); + } + } + closeTaskDrawer(projectId); } else { - await api("/api/tasks", { method: "POST", body: JSON.stringify({ data }) }); + const result = await api("/api/tasks", { method: "POST", body: JSON.stringify({ data }) }); + if (result.id && data.task) logActivity("task", result.id, "创建了任务「" + data.task + "」"); + await load(); } - await load(); } catch (error) { alert("保存失败:" + error.message); } @@ -237,18 +377,36 @@ window.createFinance = async (event) => { const data = Object.fromEntries(new FormData(form).entries()); data.tenant = state.tenant; data.sign_amount = parseFloat(data.sign_amount) || 0; - for (const m of ["2026-06","2026-07","2026-08","2026-09"]) { - const k = m.replace("-","_"); - data["rev_"+k] = parseFloat(data["rev_"+k]) || 0; - data["gross_"+k] = parseFloat(data["gross_"+k]) || 0; + data.total_rev = parseFloat(data.total_rev) || 0; + data.total_gross = parseFloat(data.total_gross) || 0; + // 收集动态预算行 + const months = form.querySelectorAll('[name="budget_month[]"]'); + const revs = form.querySelectorAll('[name="budget_rev[]"]'); + const grosses = form.querySelectorAll('[name="budget_gross[]"]'); + const payments = form.querySelectorAll('[name="budget_payment[]"]'); + const costs = form.querySelectorAll('[name="budget_cost[]"]'); + const budgetRows = []; + for (let i = 0; i < months.length; i++) { + const m = months[i].value.trim(); + if (!m) continue; + budgetRows.push({ month: m, rev: parseFloat(revs[i].value) || 0, gross: parseFloat(grosses[i].value) || 0, payment: parseFloat(payments[i].value) || 0, cost: parseFloat(costs[i].value) || 0 }); + } + data.budget_data = JSON.stringify(budgetRows); + // 同时填充到旧版12月字段以保持兼容 + for (const budgetMonth of budgetRows) { + const k = budgetMonth.month.replace("-", "_"); + data["rev_" + k] = budgetMonth.rev; + data["gross_" + k] = budgetMonth.gross; } const pfId = data.pf_id; delete data.pf_id; try { if (pfId) { await api("/api/projectFinances/" + pfId, { method: "PUT", body: JSON.stringify({ data }) }); + if (data.customer_name) logActivity("finance", pfId, "更新了「" + data.customer_name + "」的财务信息"); } else { - await api("/api/projectFinances", { method: "POST", body: JSON.stringify({ data }) }); + const result = await api("/api/projectFinances", { method: "POST", body: JSON.stringify({ data }) }); + if (result.id && data.customer_name) logActivity("finance", result.id, "创建了「" + data.customer_name + "」的财务项目"); } form.reset(); document.querySelector("#pf-id-input").value = ""; @@ -262,88 +420,249 @@ window.createFinance = async (event) => { window.switchTab = switchTab; window.switchTenant = (tenant) => { state.tenant = tenant; - state.projectView = null; + state.selectedProject = null; + localStorage.setItem("opc-active-tenant", tenant); const label = tenant.replace("·无界", ""); document.querySelector("#workspaceTitle").textContent = label + " OPC 工作台"; - document.querySelector("#tenantSelect").value = tenant; document.querySelectorAll(".workspace-nav-item").forEach(el => el.classList.toggle("active", el.dataset.tenant === tenant)); load(); }; -function renderProjects() { - // 二级页面:项目任务详情 - if (state.projectView) { - return renderProjectTasks(state.projectView); +window.doLogout = async () => { + await api("/api/auth/logout", { method: "POST" }); + window.location.href = "/login"; +}; + +// 根据用户权限调整可见工作台 +function applyUserTenants() { + fetch("/api/auth/me").then(r => r.json()).then(data => { + if (!data.logged_in) { window.location.href = "/login"; return; } + const user = data.user; + document.querySelector("#userAvatar").textContent = user.display_name.charAt(0); + document.querySelector("#userAvatar").title = user.display_name; + // 只显示有权限的工作台 + const allowedTenants = data.tenants || []; + document.querySelectorAll(".workspace-nav-item").forEach(el => { + el.style.display = allowedTenants.includes(el.dataset.tenant) ? "" : "none"; + }); + // 如果当前工作台不在允许列表中,自动切换到第一个 + if (!allowedTenants.includes(state.tenant)) { + switchTenant(allowedTenants[0]); + } + }); +} + +// 页面加载时验证登录状态 +applyUserTenants(); +window.selectProject = (id) => { + state.selectedProject = id; + renderProjects(); +}; +window.togglePhase = (phaseId) => { + const wrap = document.getElementById(phaseId); + const toggle = document.getElementById(phaseId + "-toggle"); + if (!wrap || !toggle) return; + const isCollapsed = wrap.classList.toggle("collapsed"); + toggle.innerHTML = isCollapsed ? '' : ''; + if (window.lucide) window.lucide.createIcons({ icons: { ChevronDown: "chevron-down", ChevronRight: "chevron-right" } }); +}; + +window.filterTasks = (query) => { + state.taskQuery = query; + const body = document.querySelector(".task-feed-body"); + if (body && state.selectedProject) { + body.innerHTML = renderTaskListHTML(state.selectedProject); + if (window.lucide) window.lucide.createIcons(); } +}; +window.showProjectContext = (event, id) => { + event.preventDefault(); + event.stopPropagation(); + const menu = document.querySelector("#projectContextMenu"); + if (!menu) return; + menu.dataset.projectId = id; + menu.style.left = event.clientX + "px"; + menu.style.top = event.clientY + "px"; + menu.classList.remove("hidden"); +}; +window.openProjectDrawer = () => { + const id = parseInt(document.querySelector("#projectContextMenu").dataset.projectId || "0"); + hideProjectContext(); + if (id) openDrawer("operations", id); +}; +window.hideProjectContext = () => { + const menu = document.querySelector("#projectContextMenu"); + if (menu) menu.classList.add("hidden"); +}; +window.openNewProjectModal = () => { + document.querySelector("#newProjectModal").classList.remove("hidden"); +}; +window.closeNewProjectModal = () => { + document.querySelector("#newProjectModal").classList.add("hidden"); +}; + +function renderProjects() { const items = state.data.operations; - const rows = items.map((x) => [ - `${x.project_name}`, - text(x.customer_need || x.notes), - badge(x.current_stage || x.project_status), - x.expected_contract_amount ? money(x.expected_contract_amount) : "—", - text(x.owner || "—"), - `` - ]); - document.querySelector("#projects").innerHTML = `
-
- ${card(formHtml([ - { label: "项目名称", input: `` }, - { label: "当前阶段", input: `` }, - { label: "项目金额", input: `` }, - { label: "负责人", input: `` }, - ], { handler: "createOperation", text: "新增项目" }), "p-4")} -
- ${renderTable(["项目", "项目说明", "当前阶段", "项目金额", "负责人", "进展"], rows, items.map((x) => ({ resource: "operations", id: x.id })))} -
`; + // 默认选中第一个项目 + if (!state.selectedProject && items.length > 0) { + state.selectedProject = items[0].id; + } + + document.querySelector("#projects").innerHTML = /*html*/` +
+
+
+
+ 项目 + +
+
+ ${items.map((x) => ` +
+ + ${x.project_name} +
+ `).join("")} + ${items.length === 0 ? '
暂无项目
' : ''} +
+
+
+
+ + +
+ ${state.selectedProject ? '
' + renderTaskListHTML(state.selectedProject) + '
' : ` +
+
+ +

请从左侧选择项目查看台账

+
+
`} +
+
+
`; + + // 右键菜单 + document.querySelector("#projectContextMenu")?.remove(); + const menu = document.createElement("div"); + menu.id = "projectContextMenu"; + menu.className = "project-context-menu hidden"; + menu.innerHTML = `
查看项目详情
`; + document.body.appendChild(menu); + document.removeEventListener("click", hideProjectContext); + document.addEventListener("click", hideProjectContext); + + // 创建任务抽屉 DOM + if (state.selectedProject) renderProjectTasks(state.selectedProject); + if (window.lucide) window.lucide.createIcons(); } +function filterPhaseTasks(tasks, phase) { + return tasks.filter(t => t.phase === phase); +} + +function renderTaskListHTML(projectId) { + const project = state.data.operations.find((x) => x.id === projectId); + if (!project) return ""; + const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId); + const q = (state.taskQuery || "").toLowerCase(); + const filtered = q ? tasks.filter(t => (t.task||"").toLowerCase().includes(q) || (t.notes||"").toLowerCase().includes(q)) : tasks; + const defaultPhases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"]; + const customPhases = [...new Set(filtered.map(t => t.phase).filter(Boolean))]; + // 按默认顺序排列,自定义阶段追加末尾 + const phaseOrder = [...defaultPhases]; + customPhases.forEach(p => { if (!phaseOrder.includes(p)) phaseOrder.push(p); }); + const phases = phaseOrder.filter(p => filterPhaseTasks(filtered, p).length > 0); + const phaseTasks = phases.map(p => ({ phase: p, tasks: filterPhaseTasks(filtered, p) })); + + return ` + ${phaseTasks.map(({ phase, tasks: pt }) => { + if (!pt.length) return ""; + const phaseId = "phase-" + projectId + "-" + phase.replace(/\s/g, ""); + return `
+
+ + + + ${pt.length} +
+
+
+ ${pt.map((t) => `
+ + ${t.status || '未开始'} + ${t.priority || 'P2'} +
+ ${t.task} + ${t.notes ? '' + t.notes + '' : ""} + ${t.blockers ? '\u26a0 ' + t.blockers + '' : ""} +
+ ${t.owner || ''} + ${t.due_date || ''} +
`).join("")} +
+
+
`; + }).join("")} + ${filtered.length === 0 ? (q ? '
无匹配任务
' : '
暂无任务,点击上方按钮创建
') : ''} + `; +} + function renderProjectTasks(projectId) { const project = state.data.operations.find((x) => x.id === projectId); - if (!project) { state.projectView = null; renderProjects(); return; } + if (!project) { state.selectedProject = null; renderProjects(); return; } const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId); - const phases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"]; - document.querySelector("#projects").innerHTML = `
-
- -
- ${project.project_name} - + const defaultPhases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"]; + const customPhases = [...new Set(tasks.map(t => t.phase).filter(Boolean))]; + const phases = [...new Set([...defaultPhases, ...customPhases])]; + + // 只更新任务列表体,不覆盖头部(搜索框 + 新增按钮) + const body = document.querySelector(".task-feed-body"); + if (body) body.innerHTML = renderTaskListHTML(projectId); + + // 任务抽屉放在 #projects 面板下 + let drawer = document.querySelector(`#task-drawer-${projectId}`); + if (!drawer) { + drawer = document.createElement("div"); + drawer.id = `task-drawer-${projectId}`; + drawer.className = "task-drawer"; + document.querySelector("#projects").appendChild(drawer); + } + drawer.innerHTML = `
编辑任务
+
+ + + + + + + + + +
+ +
-
-
-
- ${phases.map((phase) => { - const pt = tasks.filter((t) => t.phase === phase); - if (!pt.length) return ""; - return `
${phase}${pt.length}
${pt.map((t) => `
${t.task}${t.notes ? `${t.notes}` : ""}${t.blockers ? `⚠ ${t.blockers}` : ""}
${t.owner || ""}${t.due_date || ""}
`).join("")}
`; - }).join("")} -
-
-
编辑任务
- - - - - - - - -
- - -
- -
-
-
`; + `; if (window.lucide) window.lucide.createIcons(); } function showTaskModal(projectId) { const project = state.data.operations.find((x) => x.id === projectId); const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId); - const phases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"]; + const defaultPhases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"]; + const customPhases = [...new Set(tasks.map(t => t.phase).filter(Boolean))]; + const phases = customPhases.length ? customPhases : defaultPhases; document.querySelector("#taskModal").innerHTML = `

${project.project_name} · 任务清单

${phases.map((phase) => { @@ -378,18 +697,66 @@ window.closeTaskModal = () => { }; function renderProposals() { - const proposalRows = state.data.proposals.map((p) => [p.customer_or_project_name, p.version, badge(p.status), p.files.length + " 个"]); - const proposalClicks = state.data.proposals.map((p) => ({ resource: "proposals", id: p.id })); + const items = state.data.proposals || []; + const rows = items.map((p) => [ + `${p.customer_or_project_name}`, + p.proposal_type || "业务方案", + text(p.notes || ""), + (p.created_at || "").slice(0, 10) || "\u2014", + ]); + document.querySelector("#proposals").innerHTML = `
- ${card(formHtml([ - { label: "客户/项目", input: `` }, - { label: "版本号", input: `` }, - { label: "状态", input: `` }, - ], { handler: "createProposal", text: "新增版本" }), "p-4")} - ${renderTable(["客户/项目", "版本号", "状态", "文件数"], proposalRows, proposalClicks)} +
+ +
+ ${renderTable(["方案名称", "方案类型", "方案说明", "日期"], rows, items.map((p) => ({ resource: "proposals", id: p.id })))} +
+ `; + if (window.lucide) window.lucide.createIcons(); } +window.openProposalModal = () => { + document.querySelector("#proposalModal").classList.remove("hidden"); +}; +window.closeProposalModal = () => { + document.querySelector("#proposalModal").classList.add("hidden"); +}; +window.submitProposal = async (event) => { + event.preventDefault(); + const form = event.currentTarget; + const data = Object.fromEntries(new FormData(form).entries()); + data.tenant = state.tenant; + if (!data.version) data.version = "v1.0"; + if (!data.description) data.description = ""; + if (!data.status) data.status = "草稿"; + if (!data.created_date) data.created_date = new Date().toISOString().slice(0, 10); + try { + const result = await api("/api/proposals", { method: "POST", body: JSON.stringify({ data }) }); + if (result.id && data.customer_or_project_name) logActivity("proposal", result.id, "创建了方案「" + data.customer_or_project_name + "」"); + form.reset(); + closeProposalModal(); + await load(); + } catch (error) { + alert("保存失败:" + error.message); + } +}; + function fileGroup(module, ownerId, version, category, files) { return `

${category}

@@ -404,36 +771,128 @@ function fileItem(file) { window.deleteFile = async (fileId) => { if (!confirm("确认删除此文件?")) return; await api(`/api/files/${fileId}`, { method: "DELETE" }); - await load(); - closeDrawer(); + // 从本地 state 中移除该文件,重新打开抽屉 + for (const listKey of ["proposals", "operations", "sales", "products"]) { + if (!state.data[listKey]) continue; + for (const item of state.data[listKey]) { + if (!item.files) continue; + const idx = item.files.findIndex(f => f.id === fileId); + if (idx !== -1) { + item.files.splice(idx, 1); + openDrawer(listKey, item.id); + return; + } + } + } }; -window.uploadFile = async (event, module, ownerId, version, category) => { +window.uploadFile = (event, module, ownerId, version, category) => { const file = event.target.files[0]; if (!file) return; + const taskId = Date.now(); + const task = { id: taskId, name: file.name, progress: 0, xhr: null }; + state.uploadTasks.push(task); + renderUploadTasks(); + const form = new FormData(); form.append("module", module); form.append("owner_id", ownerId); form.append("owner_version", version); form.append("file_category", category); form.append("file", file); - await api("/api/files/upload", { method: "POST", body: form }); - await load(); + + const xhr = new XMLHttpRequest(); + task.xhr = xhr; + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) { + task.progress = Math.round((e.loaded / e.total) * 100); + renderUploadTasks(); + } + }); + xhr.addEventListener("load", () => { + if (xhr.status === 200) { + task.progress = 100; + renderUploadTasks(); + const result = JSON.parse(xhr.responseText); + const resourceMap = { proposal: "proposals", operation: "operations", sales: "sales", product: "products" }; + const listKey = resourceMap[module]; + if (listKey && state.data[listKey]) { + const item = state.data[listKey].find(x => x.id === ownerId); + if (item) { + if (!item.files) item.files = []; + item.files.push({ id: result.id, file_name: file.name, file_category: category }); + } + } + setTimeout(() => { + state.uploadTasks = state.uploadTasks.filter(t => t.id !== taskId); + renderUploadTasks(); + if (listKey) openDrawer(listKey, ownerId); + }, 600); + } + }); + xhr.addEventListener("error", () => { + state.uploadTasks = state.uploadTasks.filter(t => t.id !== taskId); + renderUploadTasks(); + }); + xhr.open("POST", "/api/files/upload"); + xhr.send(form); +}; + +window.cancelUpload = (taskId) => { + const task = state.uploadTasks.find(t => t.id === taskId); + if (task && task.xhr) task.xhr.abort(); + state.uploadTasks = state.uploadTasks.filter(t => t.id !== taskId); + renderUploadTasks(); +}; + +window.renderUploadTasks = () => { + const el = document.querySelector("#uploadTaskList"); + if (!el) return; + el.innerHTML = state.uploadTasks.map(t => ` +
+ ${t.name} +
+ ${t.progress}% + +
+ `).join(""); + el.style.display = state.uploadTasks.length ? "block" : "none"; + if (window.lucide) window.lucide.createIcons(); }; function renderProducts() { - const items = state.productPlatform === "all" ? state.data.products : state.data.products.filter((x) => (x.platform || "") === state.productPlatform); + const items = state.data.products; + const productRows = items.map((p) => ` + + ${p.status || '规划中'} + ${p.product_name} + ${p.version} + ${text(p.version_goal)} + ${text(p.feature_list)} + ${p.launch_date || '—'} + `).join(""); + document.querySelector("#products").innerHTML = `
- ${card(formHtml([ - { label: "产品名称", input: `` }, - { label: "版本号", input: `` }, - { label: "平台", input: `` }, - { label: "上线日期", input: `` }, - { label: "状态", input: `` }, - ], { handler: "createProduct", text: "新增版本" }), "p-4")} -
${[["all","全部"],["真研平台","真研"],["科普平台","科普"],["关爱平台","关爱"]].map(([k,v]) => ``).join("")}
- ${renderTable(["产品名称", "版本号", "版本目标", "核心功能", "平台", "上线日期", "状态"], items.map((p) => [p.product_name, p.version, text(p.version_goal), text(p.feature_list), text(p.platform), text(p.launch_date), badge(p.status)]), items.map((p) => ({ resource: "products", id: p.id })))} -
`; +
+

产品版本

+ +
+
+ + + + + + + + + + ${productRows} +
状态产品名称版本号版本目标核心功能上线日期
+
+
+ + `; } function renderFinance() { @@ -446,100 +905,204 @@ function renderFinance() { }; const fmTypes = fmTypesByTenant[state.tenant] || fmTypesByTenant["科普·无界"]; const tenantOps = (state.data.operations || []).filter(o => (o.project_name || "").includes(state.tenant.replace("·无界","")) || o.tenant === state.tenant); - const months = ["2026-06","2026-07","2026-08","2026-09"]; - const monthLabels = ["6月","7月","8月","9月"]; + const now = new Date(); + const thisMonth = now.getMonth() + 1; // 1-12 + const displayMonths = []; + for (let i = 0; i < 4; i++) { + const m = thisMonth + i; + const mm = m > 12 ? m - 12 : m; + displayMonths.push({ key: "2026_" + String(mm).padStart(2, "0"), label: mm + "月" }); + } + const months = displayMonths.map(d => d.key); + const monthLabels = displayMonths.map(d => d.label); // Aggregates - const signed = pfs.filter(x => x.status === "已签单"); - const pending = pfs.filter(x => x.status !== "已签单"); - const sumSign = signed.reduce((s,x) => s + (x.sign_amount||0), 0); - const sumPending = pending.reduce((s,x) => s + (x.sign_amount||0), 0); - const monthRev = months.map(m => pfs.reduce((s,x) => s + (x["rev_"+m.replace("-","_")]||0), 0)); - const monthGross = months.map(m => pfs.reduce((s,x) => s + (x["gross_"+m.replace("-","_")]||0), 0)); + const signed = pfs.filter(x => x.status === "已签约"); + const inContract = pfs.filter(x => x.status === "流程中"); + const pending = pfs.filter(x => x.status === "待签约"); + const sumSign = Math.round(signed.reduce((s,x) => s + (x.sign_amount||0), 0)); + const sumPending = Math.round(pending.reduce((s,x) => s + (x.sign_amount||0), 0)); + const sumContract = Math.round(inContract.reduce((s,x) => s + (x.sign_amount||0), 0)); + const monthRev = months.map(m => signed.reduce((s,x) => s + (x["rev_"+m]||0), 0)); + const monthGross = months.map(m => signed.reduce((s,x) => s + (x["gross_"+m]||0), 0)); + + // 本月财务指标(从 budget_data 汇总) + const thisMonthKey = displayMonths[0].key; // "2026_06" + const thisMonthRev = monthRev[0]; + const thisMonthGross = monthGross[0]; + let monthPayment = 0, monthCost = 0; + for (const pf of pfs) { + let budget = []; + try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {} + for (const b of budget) { + const bKey = (b.month || "").replace("-", "_"); + if (bKey === thisMonthKey) { + monthPayment += parseFloat(b.payment || 0); + monthCost += parseFloat(b.cost || 0); + break; + } + } + } + monthPayment = Math.round(monthPayment); + monthCost = Math.round(monthCost); + const monthCashflow = monthPayment - monthCost; const renderPfRow = (pf) => { const mCols = months.map(m => { - const rev = pf["rev_"+m.replace("-","_")] || 0; - const gross = pf["gross_"+m.replace("-","_")] || 0; + const rev = pf["rev_"+m] || 0; + const gross = pf["gross_"+m] || 0; return `${rev ? money(rev) : '—'}
${gross ? money(gross) : '—'}`; }).join(""); - const totalRev = months.reduce((s,m) => s + (pf["rev_"+m.replace("-","_")]||0), 0); - const totalGross = months.reduce((s,m) => s + (pf["gross_"+m.replace("-","_")]||0), 0); + const totalRev = pf.total_rev || 0; + const totalGross = pf.total_gross || 0; const totalCol = `${totalRev ? money(totalRev) : '—'}
${totalGross ? money(totalGross) : '—'}`; - return `${pf.customer_name}${pf.business_type}${pf.status === "已签单" ? badge("已签") : badge(pf.status,"amber")}${money(pf.sign_amount)}${mCols}${totalCol}${pf.sales_person || ""}`; + const sm = pf.sign_month || ""; + const signMonthCell = `${sm || '—'}`; + return `${pf.customer_name}${pf.business_type}${pf.status === "已签约" ? badge("已签约") : pf.status === "流程中" ? badge("流程中","blue") : badge("待签约","amber")}${signMonthCell}${money(pf.sign_amount)}${mCols}${totalCol}${pf.sales_person || ""}`; }; document.querySelector("#finance").innerHTML = `
- ${[["已签项目","" + signed.length],["签约金额",money(sumSign)],["待签项目","" + pending.length],["待签金额",money(sumPending)],["本月确收",money(monthRev[0])],["本月毛利",money(monthGross[0])]].map(([l,v]) => `

${l}

${v}

`).join("")} + ${[["已签项目","" + signed.length],["签约金额",money(sumSign)],["流程项目","" + inContract.length],["流程金额",money(sumContract)],["待签项目","" + pending.length],["待签金额",money(sumPending)]].map(([l,v]) => `

${l}

${v}

`).join("")} +
+
+ ${[["本月确收",money(thisMonthRev)],["本月毛利",money(thisMonthGross)],["本月回款",money(monthPayment)],["本月费用",money(monthCost)],["本月现金流",money(monthCashflow)]].map(([l,v]) => `

${l}

${v}

`).join("")}
- ${card(`

月度趋势

`, "p-5")} -
- - - ${card(`

项目明细 (${pfs.length})

${[["已签单","已签"],["待签","待签"]].map(([k,v]) => ``).join("")}
${monthLabels.map(l => ``).join("")}${pfs.filter(x => x.status === state.finFilter).map(renderPfRow).join("")}
客户类型状态签约金额${l}
确收/毛利
总计
确收/毛利
销售
`, "p-4")} + + ${card(`

项目明细 (${pfs.length})

${[["已签约","已签约"],["流程中","流程中"],["待签约","待签约"]].map(([k,v]) => ``).join("")}
${monthLabels.map(l => ``).join("")}${pfs.filter(x => x.status === state.finFilter).map(renderPfRow).join("")}
客户类型状态签约月份签约金额${l}
确收/毛利
总计
确收/毛利
销售
`, "p-4")}
`; - if (window.lucide) window.lucide.createIcons(); } - window.openFinanceModal = () => { const modal = document.querySelector("#financeModal"); + const form = modal.querySelector("form"); + form.querySelector('[name="project_id"]').value = state.tenant; + const dept = form.querySelector('input[disabled]'); + if (dept) dept.value = state.tenant; + // 新增时初始化默认12行(编辑时不重置) + const pfIdInput = form.querySelector('[name="pf_id"]'); + if (!pfIdInput || !pfIdInput.value) { + initBudgetTable(null); + document.querySelector("#financeTransferBtn").classList.add("hidden"); + } modal.classList.remove("hidden"); }; + +window.addBudgetRow = (month = '', rev = '', gross = '', payment = '', cost = '') => { + const tbody = document.querySelector("#budgetTbody"); + if (!tbody) return; + const row = document.createElement("tr"); + row.innerHTML = ` + + + + + `; + tbody.appendChild(row); + if (window.lucide) window.lucide.createIcons(); +}; + +window.initBudgetTable = (budgetData) => { + const tbody = document.querySelector("#budgetTbody"); + if (!tbody) return; + tbody.innerHTML = ""; + const rows = budgetData || []; + if (rows.length === 0) { + // 默认当前年份1-12月 + const year = new Date().getFullYear(); + for (const m of ["01","02","03","04","05","06","07","08","09","10","11","12"]) { + addBudgetRow(year + "-" + m); + } + } else { + rows.forEach(r => addBudgetRow(r.month || '', r.rev || '', r.gross || '', r.payment || '', r.cost || '')); + } +}; window.closeFinanceModal = () => { const modal = document.querySelector("#financeModal"); modal.classList.add("hidden"); }; +window.editPfSignMonth = (event, pfId) => { + event.stopPropagation(); + const pf = (state.data.projectFinances || []).find(x => x.id === pfId); + if (!pf) return; + const span = event.currentTarget; + const td = span.parentElement; + const currentValue = pf.sign_month || ""; + const select = document.createElement("select"); + select.innerHTML = monthOptions(currentValue); + select.className = "w-full rounded border border-slate-200 px-1 py-1 text-sm"; + select.value = currentValue; + select.addEventListener("change", async () => { + const newValue = select.value; + try { + await api(`/api/projectFinances/${pfId}`, { method: "PUT", body: JSON.stringify({ data: { sign_month: newValue } }) }); + pf.sign_month = newValue; + td.innerHTML = `${newValue || '—'}`; + } catch (e) { alert("修改失败:" + e.message); } + }); + select.addEventListener("blur", () => { + td.innerHTML = `${currentValue || '—'}`; + }); + td.innerHTML = ""; + td.appendChild(select); + select.focus(); +}; + +window.switchFinanceTab = (tab) => { + document.querySelectorAll(".finance-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab)); + document.querySelector("#financeTabInfo").classList.toggle("hidden", tab !== "info"); + document.querySelector("#financeTabBudget").classList.toggle("hidden", tab !== "budget"); +}; window.openPfEditModal = (pfId) => { const pf = (state.data.projectFinances || []).find(x => x.id === pfId); if (!pf) return; document.querySelector("#pf-id-input").value = pf.id; document.querySelector("#financeModalTitle").textContent = "编辑项目财务"; + document.querySelector("#financeTransferBtn").classList.remove("hidden"); const form = document.querySelector("#financeModal form"); form.querySelector('[name="project_id"]').value = pf.project_id || ""; + const deptDisplay = form.querySelector('.bg-slate-50 [disabled]'); + if (deptDisplay) deptDisplay.value = pf.project_id || ""; form.querySelector('[name="business_type"]').value = pf.business_type || ""; form.querySelector('[name="customer_name"]').value = pf.customer_name || ""; form.querySelector('[name="sign_amount"]').value = pf.sign_amount || ""; - form.querySelector('[name="sign_month"]').value = pf.sign_month || ""; - form.querySelector('[name="status"]').value = pf.status || "已签单"; - for (const m of ["06","07","08","09"]) { - form.querySelector('[name="rev_2026_' + m + '"]').value = pf["rev_2026_" + m] || ""; - form.querySelector('[name="gross_2026_' + m + '"]').value = pf["gross_2026_" + m] || ""; + const signMonthValue = pf.sign_month || ""; + const signMonthEl = form.querySelector('[name="sign_month"]'); + if (signMonthEl && signMonthValue) { + signMonthEl.innerHTML = monthOptions(signMonthValue); + signMonthEl.value = signMonthValue; } - // Calculate totals - const totalRev = (pf.rev_2026_06||0) + (pf.rev_2026_07||0) + (pf.rev_2026_08||0) + (pf.rev_2026_09||0); - const totalGross = (pf.gross_2026_06||0) + (pf.gross_2026_07||0) + (pf.gross_2026_08||0) + (pf.gross_2026_09||0); - document.querySelector("#total-rev-display").value = money(totalRev); - document.querySelector("#total-gross-display").value = money(totalGross); + form.querySelector('[name="status"]').value = pf.status || "待签约"; + form.querySelector('[name="total_rev"]').value = pf.total_rev || ""; + form.querySelector('[name="total_gross"]').value = pf.total_gross || ""; + // 回填预算表 + let budgetData = []; + try { budgetData = JSON.parse(pf.budget_data || "[]"); } catch (e) { budgetData = []; } + if (budgetData.length === 0) { + // 如果没有新数据,从旧12月字段构建 + for (const m of ["01","02","03","04","05","06","07","08","09","10","11","12"]) { + const rev = pf["rev_2026_" + m] || 0; + const gross = pf["gross_2026_" + m] || 0; + if (rev || gross) budgetData.push({ month: "2026-" + m, rev: rev, gross: gross }); + } + } + initBudgetTable(budgetData.length ? budgetData : null); openFinanceModal(); }; -window.toggleFinanceChart = () => { - const wrap = document.querySelector("#financeChartWrap"); - const icon = document.querySelector("#financeChartIcon"); - if (!wrap) return; - wrap.classList.toggle("hidden"); - icon.classList.toggle("rotate-90"); - if (!wrap.classList.contains("hidden") && !state.chart2) renderFinanceChart(); -}; -function renderFinanceChart() { - const { financeMonthly } = state.data; - const canvas = document.querySelector("#financeChart2"); - if (!canvas || !window.Chart) return; - if (state.chart2) state.chart2.destroy(); - state.chart2 = new Chart(canvas, { - type: "line", - data: { - labels: financeMonthly.map((x) => x.month), - datasets: [ - { label: "月度确收", data: financeMonthly.map((x) => x.revenue), borderColor: "#2563eb", tension: 0.3 }, - { label: "月度毛利", data: financeMonthly.map((x) => x.net_profit), borderColor: "#059669", tension: 0.3 }, - ], - }, - options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } }, scales: { x: { ticks: { font: { size: 11 } }, grid: { display: false } }, y: { ticks: { font: { size: 11 }, callback: (v) => v + "万" } } } }, - }); -} - function renderChartOn(id, data) { const canvas = document.querySelector(`#${id}`); if (!canvas || !window.Chart) return; @@ -581,8 +1144,8 @@ function openDrawer(resource, id) { : resource === "operations" ? [["project_name","项目名称"],["owner","负责人"],["expected_sign_date","截止时间"],["expected_contract_amount","金额"],["notes","项目说明"]] : resource === "proposals" - ? [["customer_or_project_name","客户/项目"],["version","版本号"],["description","版本说明"],["created_date","创建日期"],["status","状态"]] - : [["product_name","产品名称"],["version","版本号"],["version_goal","版本目标"],["feature_list","核心功能清单"],["platform","平台"],["launch_date","上线日期"],["status","当前状态"],["notes","备注"]]; + ? [["customer_or_project_name","客户/项目"],["proposal_type","方案类型"],["notes","备注"]] + : [["product_name","产品名称"],["version","版本号"],["version_goal","版本目标"],["feature_list","核心功能"],["launch_date","上线日期"],["status","状态"],["notes","备注"]]; const fieldIcons = { target_customer: "user", priority: "flag", status: "circle-dot", project_name: "briefcase-business", project_version: "git-branch", project_status: "circle-dot", current_stage: "map-pin", @@ -590,12 +1153,12 @@ function openDrawer(resource, id) { sign_probability: "percent", sop_stage: "list-checks", execution_progress: "activity", current_deliverable: "package", risks: "alert-triangle", next_action: "arrow-right", product_name: "box", version: "tag", version_goal: "target", feature_list: "list", platform: "layers", - launch_date: "calendar", notes: "sticky-note" + launch_date: "calendar", notes: "sticky-note", proposal_type: "tag", customer_or_project_name: "building" }; const multilineFields = ["customer_need", "current_deliverable", "risks", "next_action", "version_goal", "feature_list", "notes"]; const followupTarget = resource === "sales" ? "sales" : resource === "proposals" ? "proposal" : resource === "operations" ? "operation" : resource === "products" ? "product" : ""; - const title = item.target_customer || item.project_name || item.customer_or_project_name || item.product_name; - drawer.innerHTML = `

Detail Drawer

${title}

+ const title = item.target_customer || item.project_name || (item.customer_or_project_name ? `${item.customer_or_project_name} · ${item.proposal_type || ''}` : "") || item.product_name; + drawer.innerHTML = `

Detail Drawer

${title}

属性

@@ -603,7 +1166,7 @@ function openDrawer(resource, id) { ${fields.map(([key,label]) => drawerField(fieldIcons[key] || "circle", label, key, item[key], multilineFields.includes(key))).join("")}
- ${resource === "proposals" ? `

方案文件

${["方案","成本","SOP","财务流程"].map((cat) => fileGroup("proposal", item.id, item.version, cat, item.files.filter((f) => f.file_category === cat))).join("")}
` : ""} + ${resource === "proposals" ? `

附件

${fileGroup("proposal", item.id, "", "附件", item.files || [])}
` : ""} ${followupTarget ? `

活动 / 跟进

${(item.followups || []).map((f) => `
${f.follower} · ${f.follow_up_method}${f.followed_at}
${f.next_action ? `

下一步:${text(f.next_action)}

` : ""}
`).join("")}
@@ -628,10 +1191,12 @@ function openDrawer(resource, id) {
` : ""} +
`; drawer.classList.add("open"); bindDrawerAutosave(resource, item.id, item); if (window.lucide) window.lucide.createIcons(); + renderUploadTasks(); // Decode and render rich HTML content in followup records drawer.querySelectorAll(".rich-content").forEach((el) => { const html = el.dataset.html; @@ -700,20 +1265,83 @@ window.openDrawer = openDrawer; window.deleteDrawerItem = async (resource, id) => { if (!confirm("确认删除?此操作不可撤销。")) return; try { + // 记录删除前的名称用于活动日志 + const listKey = { sales: "sales", proposals: "proposals", operations: "operations", products: "products" }[resource]; + let name = ""; + if (listKey && state.data[listKey]) { + const item = state.data[listKey].find(x => x.id === id); + name = item ? (item.target_customer || item.project_name || item.customer_or_project_name || item.product_name || "") : ""; + } await api(`/api/${resource}/${id}`, { method: "DELETE" }); + if (name) { + const resType = { sales: "sales", proposals: "proposal", operations: "operation", products: "product" }[resource] || resource; + logActivity(resType, id, "删除了「" + name + "」"); + } closeDrawer(); await load(); } catch (error) { alert("删除失败:" + error.message); } }; -window.toggleTaskDone = async (taskId, projectId) => { - const task = (state.data.tasks || []).find((t) => t.id === taskId); + +window.openTransferModal = (resource, id, title) => { + document.querySelector("#transfer-resource").value = resource; + document.querySelector("#transfer-id").value = id; + document.querySelector("#transfer-title-text").textContent = "将「" + title + "」转移到:"; + document.querySelector("#transferModal").classList.remove("hidden"); +}; + +window.closeTransferModal = () => { + document.querySelector("#transferModal").classList.add("hidden"); +}; + +window.submitTransfer = async (event) => { + event.preventDefault(); + const form = event.currentTarget; + const resource = form.querySelector('[name="transfer_resource"]').value; + const id = form.querySelector('[name="transfer_id"]').value; + const newTenant = form.querySelector('[name="transfer_tenant"]').value; + try { + await api(`/api/${resource}/${id}`, { method: "PUT", body: JSON.stringify({ data: { tenant: newTenant } }) }); + closeTransferModal(); + closeDrawer(); + await load(); + } catch (error) { + alert("转移失败:" + error.message); + } +}; + +window.transferFinance = async () => { + const pfId = document.querySelector("#pf-id-input").value; + if (!pfId) return; + const pf = (state.data.projectFinances || []).find(x => x.id === parseInt(pfId)); + if (!pf) return; + const title = pf.customer_name || "财务项目"; + document.querySelector("#transfer-resource").value = "projectFinances"; + document.querySelector("#transfer-id").value = pfId; + document.querySelector("#transfer-title-text").textContent = "将「" + title + "」的财务数据转移到:"; + document.querySelector("#transferModal").classList.remove("hidden"); +}; + +window.cycleTaskStatus = async (taskId, projectId) => { + const tasks = state.data.tasks || []; + const task = tasks.find((t) => t.id === taskId); if (!task) return; - const newStatus = task.status === "done" ? "" : "done"; + const statuses = ["未开始", "进行中", "验收中", "已结束"]; + const current = statuses.indexOf(task.status) >= 0 ? task.status : "未开始"; + const newStatus = statuses[(statuses.indexOf(current) + 1) % statuses.length]; try { await api(`/api/tasks/${taskId}`, { method: "PUT", body: JSON.stringify({ data: { status: newStatus } }) }); - await load(); + task.status = newStatus; + const row = document.querySelector(`.task-item[data-id="${taskId}"]`); + if (row) { + row.classList.toggle("task-done", newStatus === "已结束"); + const badge = row.querySelector(".task-status-badge"); + if (badge) { + badge.textContent = newStatus; + badge.className = "task-status-badge status-" + newStatus; + } + } } catch (error) { alert("更新失败:" + error.message); } @@ -723,9 +1351,15 @@ window.deleteTask = async (projectId) => { if (!taskId) return; if (!confirm("确认删除该任务?此操作不可撤销。")) return; try { + const task = (state.data.tasks || []).find(t => t.id === parseInt(taskId)); + const taskName = task ? task.task : ""; await api(`/api/tasks/${taskId}`, { method: "DELETE" }); + if (taskName) logActivity("task", taskId, "删除了任务「" + taskName + "」"); closeTaskDrawer(projectId); - await load(); + state.data.tasks = (state.data.tasks || []).filter(t => t.id !== parseInt(taskId)); + // 只移除该 DOM 行,不重渲染 + const row = document.querySelector(`.task-item[data-id="${taskId}"]`); + if (row) row.remove(); } catch (error) { alert("删除失败:" + error.message); } @@ -742,7 +1376,7 @@ window.handleTaskDrop = async (event, projectId, phase) => { const target = event.currentTarget; if (!dragTaskId) return; // Find the dragged element and insert after the nearest task - const dragged = document.querySelector(`.task-row[data-id="${dragTaskId}"]`); + const dragged = document.querySelector(`.task-item[data-id="${dragTaskId}"]`); if (!dragged) return; const afterElement = getDragAfterElement(target, event.clientY); if (afterElement) { @@ -752,7 +1386,7 @@ window.handleTaskDrop = async (event, projectId, phase) => { } dragged.classList.remove("dragging"); // Update sort_order in DB - const rows = [...target.querySelectorAll(".task-row")]; + const rows = [...target.querySelectorAll(".task-item")]; const updates = rows.map((row, i) => ({ id: parseInt(row.dataset.id), sort_order: i })); try { await api(`/api/tasks/batch-sort`, { method: "POST", body: JSON.stringify({ items: updates }) }); @@ -760,7 +1394,7 @@ window.handleTaskDrop = async (event, projectId, phase) => { dragTaskId = null; }; function getDragAfterElement(container, y) { - const elements = [...container.querySelectorAll(".task-row:not(.dragging)")]; + const elements = [...container.querySelectorAll(".task-item:not(.dragging)")]; return elements.reduce((closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; @@ -809,6 +1443,12 @@ window.submitComment = async (event, targetType, targetId, resource) => { openDrawer(resource, targetId); }; +window.deleteActivity = async (id) => { + if (!confirm("确认删除这条动态?")) return; + await api(`/api/followups/${id}`, { method: "DELETE" }); + await load(); +}; + window.deleteFollowup = async (event, followupId, resource, targetId) => { event.stopPropagation(); if (!confirm("确认删除这条评论?")) return; @@ -821,7 +1461,18 @@ document.querySelector("#tabs").addEventListener("click", (event) => { const button = event.target.closest("button[data-tab]"); if (button) switchTab(button.dataset.tab); }); -document.querySelector("#refreshBtn").addEventListener("click", load); -load().catch((error) => { +// 恢复上次的工作台和标签页 +const savedTenant = localStorage.getItem("opc-active-tenant"); +if (savedTenant) { + state.tenant = savedTenant; + document.querySelectorAll(".workspace-nav-item").forEach(el => el.classList.toggle("active", el.dataset.tenant === savedTenant)); + const label = savedTenant.replace("·无界", ""); + document.querySelector("#workspaceTitle").textContent = label + " OPC 工作台"; +} +const savedTab = localStorage.getItem("opc-active-tab"); + +load().then(() => { + if (savedTab && savedTab !== "home") switchTab(savedTab); +}).catch((error) => { document.querySelector("main").innerHTML = `
加载失败:${error.message}
`; }); diff --git a/static/styles.css b/static/styles.css index cebaa5a..f1e0ff4 100644 --- a/static/styles.css +++ b/static/styles.css @@ -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; diff --git a/templates/index.html b/templates/index.html index df6bc8f..cc5b35a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -42,6 +42,11 @@ 医患
+ +
+
+ +
@@ -51,11 +56,6 @@

OPC Manager

科普 OPC 工作台

-
@@ -63,10 +63,10 @@
@@ -80,6 +80,47 @@
+ + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..2e1d600 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,45 @@ + + + + + + OPC 工作台 · 登录 + + + +
+
+

OPC 工作台

+

请输入账号密码登录

+
+
+ + + + +
+

默认管理员:qiukai / yxcowork2026

+
+ + +