diff --git a/.gitignore b/.gitignore index 5992178..1828302 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ data/uploads/ __pycache__/ .DS_Store data/opc.sqlite +.env diff --git a/VERSION_LOG.md b/VERSION_LOG.md index 497a99c..bff149d 100644 --- a/VERSION_LOG.md +++ b/VERSION_LOG.md @@ -1,5 +1,34 @@ # OPC Manager Version Log +## v1.1.0-beta — 2026-06-23 +- **安全**:.env 环境变量管理(SECRET_KEY/DB)、debug=False、except 改 mysql.connector.Error + logging +- **性能**:attach_common 批量 IN 查询消除 N+1、monthly_finance 预解析 budget_data +- **XSS**:批量添加 esc() 转义 21 处用户可控字段 +- **架构**:app.js 拆分为 7 个模块(utils/home/projects/proposals/products/finance/drawer)+ admin.js +- **表单统一**:.form-ctrl 统一所有输入控件(替代 drawer-value/fin-input/inline-form 等) +- **首页**:移除风险提醒卡片;财务趋势拆为 3 图(签约趋势/确收毛利/回款费用) +- **业务方案**:标准资料库 + 其他资料双 Tab;标准 7 项自动初始化;标准资料支持评论 +- **经营管理**: + - 字段改名:客户名称→项目名称、销售人员→商务负责人 + - 必填约束:项目名称/商务负责人/经营负责人/签约月份/签约金额>0 + - 新增经营负责人字段;移除转移功能,新增删除项目 + - 视图切换:确收/毛利 ↔ 回款/费用 +- **重点工作与台账**: + - 统计卡片样式与经营管理统一(无图标) + - 视图切换 + 新增任务按钮移到卡片外右对齐 + - 任务状态简化为 3 态(未开始/进行中/已结束) + - 优先级点击切换、项目右键菜单(重命名/创建副本) + - 新建任务绑定当前选中项目(修复新建到错误项目的 bug) + - 任务详情抽屉改为创建到 document.body(避免 innerHTML 清除) +- **用户体系**: + - 新增工作台:MCN·无界、无界·无界 + - 新增账号:mcn/mcn123、wuji/wuji123 + - 账号管理后台(admin 限定):增删改查账号 + 工作台权限分配 + - sidebar 顶部用户头像(首字母)+ 显示名,点击弹菜单(账号管理/退出) + - sidebar sticky 定位,滚动时不消失 +- **登录页**:参考 UOC 业务管理平台样式优化(图标 logo、密码显隐、loading 态、回车提交) +- **初始化**:启动自动修正任务状态空值/done→进行中等非法值 + ## v1.0.1-beta — 2026-06-22 - 数据库迁移:SQLite → MySQL 9.6,适配占位符/类型/游标 - 用户体系:管理员 + OPC负责人角色,工作台权限隔离,登录鉴权 diff --git a/backend/flask_app.py b/backend/flask_app.py index 2ff6770..69ba75b 100644 --- a/backend/flask_app.py +++ b/backend/flask_app.py @@ -1,19 +1,31 @@ from datetime import date, datetime from pathlib import Path import os +import json import shutil import sqlite3 # 保留用于数据迁移 +import logging import mysql.connector from flask import Flask, jsonify, render_template, request, send_file, session, redirect from werkzeug.security import generate_password_hash, check_password_hash +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") + ROOT = Path(__file__).resolve().parents[1] DATA_DIR = ROOT / "data" UPLOAD_DIR = DATA_DIR / "uploads" DB_PATH = DATA_DIR / "opc.sqlite" -WEIXIN_BASE = Path("/Users/mac/天机阁/地阁/慰心斋") + +try: + from dotenv import load_dotenv + load_dotenv(ROOT / ".env") +except ImportError: + pass + +WEIXIN_BASE = Path(os.environ.get("WEIXIN_BASE", "/Users/mac/天机阁/地阁/慰心斋")) DATA_DIR.mkdir(parents=True, exist_ok=True) UPLOAD_DIR.mkdir(parents=True, exist_ok=True) @@ -36,6 +48,21 @@ def login_required(f): return f(*args, **kwargs) return decorated + +def admin_required(f): + from functools import wraps + @wraps(f) + def decorated(*args, **kwargs): + if "user_id" not in session: + return jsonify({"error": "未登录"}), 401 + if session.get("role") != "admin": + return jsonify({"error": "无权限"}), 403 + return f(*args, **kwargs) + return decorated + + +ALL_TENANTS = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "无界·无界"] + @app.route("/login") def login_page(): return render_template("login.html") @@ -57,7 +84,7 @@ def auth_login(): session["role"] = user["role"] # 管理员可看所有工作台,OPC负责人看分配的工作台 if user["role"] == "admin": - session["tenants"] = ["科普·无界", "科研·无界", "医患·无界"] + session["tenants"] = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "无界·无界"] else: ut = rows(conn, "SELECT tenant FROM user_tenants WHERE user_id=?", (user["id"],)) session["tenants"] = [x["tenant"] for x in ut] @@ -87,16 +114,128 @@ def auth_me(): }) +# ---------- 账号管理 API ---------- + +@app.route("/api/users") +@admin_required +def list_users(): + conn = db() + try: + users = rows(conn, "SELECT id, username, display_name, role, created_at FROM users ORDER BY id") + ut_rows = rows(conn, "SELECT user_id, tenant FROM user_tenants") + tenant_map = {} + for r in ut_rows: + tenant_map.setdefault(r["user_id"], []).append(r["tenant"]) + for u in users: + u["tenants"] = tenant_map.get(u["id"], []) + return jsonify(users) + finally: + conn.close() + + +@app.route("/api/users", methods=["POST"]) +@admin_required +def create_user(): + data = request.get_json(force=True) + username = (data.get("username") or "").strip() + display_name = (data.get("display_name") or "").strip() + password = data.get("password") or "" + role = data.get("role") or "opc_owner" + tenants = data.get("tenants") or [] + if not username or not password or not display_name: + return jsonify({"error": "用户名/密码/显示名不能为空"}), 400 + if role not in ("admin", "opc_owner"): + return jsonify({"error": "角色非法"}), 400 + conn = db() + try: + if one(conn, "SELECT id FROM users WHERE username=?", (username,)): + return jsonify({"error": "用户名已存在"}), 400 + _exec(conn, "INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)", + (username, generate_password_hash(password, "pbkdf2:sha256"), display_name, role, date.today().isoformat())) + u = one(conn, "SELECT id FROM users WHERE username=?", (username,)) + for t in tenants: + if t in ALL_TENANTS: + _exec(conn, "INSERT IGNORE INTO user_tenants (user_id, tenant) VALUES (?,?)", (u["id"], t)) + conn.commit() + return jsonify({"ok": True, "id": u["id"]}) + finally: + conn.close() + + +@app.route("/api/users/", methods=["PUT"]) +@admin_required +def update_user(uid): + data = request.get_json(force=True) + conn = db() + try: + u = one(conn, "SELECT * FROM users WHERE id=?", (uid,)) + if not u: + return jsonify({"error": "用户不存在"}), 404 + display_name = (data.get("display_name") or "").strip() or u["display_name"] + role = data.get("role") or u["role"] + if role not in ("admin", "opc_owner"): + return jsonify({"error": "角色非法"}), 400 + password = data.get("password") or "" + if password: + _exec(conn, "UPDATE users SET display_name=?, role=?, password_hash=? WHERE id=?", + (display_name, role, generate_password_hash(password, "pbkdf2:sha256"), uid)) + else: + _exec(conn, "UPDATE users SET display_name=?, role=? WHERE id=?", (display_name, role, uid)) + # 更新工作台权限 + if "tenants" in data: + _exec(conn, "DELETE FROM user_tenants WHERE user_id=?", (uid,)) + for t in data["tenants"]: + if t in ALL_TENANTS: + _exec(conn, "INSERT IGNORE INTO user_tenants (user_id, tenant) VALUES (?,?)", (uid, t)) + # 不允许删除最后一个 admin + if role != "admin": + admin_count = one(conn, "SELECT COUNT(*) AS c FROM users WHERE role='admin'")["c"] + if admin_count == 0: + return jsonify({"error": "至少保留一个管理员"}), 400 + conn.commit() + return jsonify({"ok": True}) + finally: + conn.close() + + +@app.route("/api/users/", methods=["DELETE"]) +@admin_required +def delete_user(uid): + if uid == session.get("user_id"): + return jsonify({"error": "不能删除当前登录账号"}), 400 + conn = db() + try: + u = one(conn, "SELECT * FROM users WHERE id=?", (uid,)) + if not u: + return jsonify({"error": "用户不存在"}), 404 + # 不允许删除最后一个 admin + if u["role"] == "admin": + admin_count = one(conn, "SELECT COUNT(*) AS c FROM users WHERE role='admin'")["c"] + if admin_count <= 1: + return jsonify({"error": "至少保留一个管理员"}), 400 + _exec(conn, "DELETE FROM user_tenants WHERE user_id=?", (uid,)) + _exec(conn, "DELETE FROM users WHERE id=?", (uid,)) + conn.commit() + return jsonify({"ok": True}) + finally: + conn.close() + + +@app.route("/api/tenants") +def list_tenants(): + return jsonify(ALL_TENANTS) + + # ---------- 业务 API ---------- def db(): return mysql.connector.connect( - host="127.0.0.1", - port=3306, - user="opc", - password="opc123456", - database="opc", + host=os.environ.get("DB_HOST", "127.0.0.1"), + port=int(os.environ.get("DB_PORT", "3306")), + user=os.environ.get("DB_USER", "opc"), + password=os.environ.get("DB_PASSWORD", "opc123456"), + database=os.environ.get("DB_NAME", "opc"), charset="utf8mb4", collation="utf8mb4_unicode_ci", ) @@ -263,7 +402,7 @@ def init_db(): role VARCHAR(50) NOT NULL DEFAULT 'opc_owner', created_at VARCHAR(30) NOT NULL DEFAULT '' )""") - except: pass + except mysql.connector.Error as e: logger.debug(f"users table: {e}") conn.commit() # 用户-工作台关联表 @@ -273,7 +412,7 @@ def init_db(): tenant VARCHAR(100) NOT NULL, UNIQUE KEY (user_id, tenant) )""") - except: pass + except mysql.connector.Error as e: logger.debug(f"user_tenants table: {e}") conn.commit() # project_finances 表(月度预算 + 签约信息) @@ -293,7 +432,7 @@ def init_db(): created_at VARCHAR(30) NOT NULL DEFAULT '', updated_at VARCHAR(30) NOT NULL DEFAULT '' )""") - except: pass + except mysql.connector.Error as e: logger.debug(f"project_finances table: {e}") conn.commit() # Schema migrations — 添加后续迁移的列(幂等) @@ -321,9 +460,18 @@ def init_db(): for mig in migrations: try: _exec(conn, mig) - except: pass + except mysql.connector.Error as e: logger.debug(f"migration skipped: {e}") conn.commit() + # 数据修正:status 为空或 'done' 的任务修正为合法值 + try: + _exec(conn, "UPDATE project_tasks SET status='未开始' WHERE status='' OR status IS NULL") + _exec(conn, "UPDATE project_tasks SET status='已结束' WHERE status='done'") + _exec(conn, "UPDATE project_tasks SET status='进行中' WHERE status='验收中'") + conn.commit() + except mysql.connector.Error as e: + logger.warning(f"task status fix failed: {e}") + # 初始化默认用户(只执行一次) if not one(conn, "SELECT id FROM users LIMIT 1"): _exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""", @@ -334,135 +482,147 @@ def init_db(): ("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())) + _exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""", + ("mcn", generate_password_hash("mcn123", "pbkdf2:sha256"), "MCN负责人", "opc_owner", date.today().isoformat())) + _exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""", + ("wuji", generate_password_hash("wuji123", "pbkdf2:sha256"), "无界负责人", "opc_owner", date.today().isoformat())) # 各 OPC 负责人绑定工作台 - for uname, tenant in [("kepu","科普·无界"),("keyan","科研·无界"),("yihuan","医患·无界")]: + for uname, tenant in [("kepu","科普·无界"),("keyan","科研·无界"),("yihuan","医患·无界"),("mcn","MCN·无界"),("wuji","无界·无界")]: 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() - return - - sales = [ - ("齐鲁制药", "P0", "跟进中", "多产品线科普年度框架,需推进高层沟通。"), - ("百利天恒", "P0", "方案中", "BL-B01D1 上市前医生教育机会,准备方案。"), - ("信达生物", "P0", "已签约", "现有科普项目升级/续约,重点保障执行。"), - ("三生制药", "P1", "待跟进", "多科室医生教育+患者科普机会。"), - ("天广实生物", "P1", "待跟进", "血液肿瘤医生教育机会。"), - ] - for customer, priority, status, note in sales: - cur = _exec(conn, - "INSERT INTO sales_leads (target_customer, priority, status) VALUES (?,?,?)", - (customer, priority, status), - ) - _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 = _exec(conn, - "INSERT INTO business_proposals (customer_or_project_name,version,description,status,created_date) VALUES (?,?,?,?,?)", - ("信达生物", "v1.5", "信达科普项目续约与报价方案", "已提交客户", "2026-05-28"), - ) - proposal_id = cur.lastrowid - proposal_dir = WEIXIN_BASE / "2、业务方案/信达/v1.5" - for category, names in { - "方案": ["整体方案.pptx", "整体方案.pdf"], - "成本": ["业务报价-2亿方案.xlsx", "业务报价-5250万方案.xlsx", "5、最新报价.xlsx"], - "SOP": ["SOP.docx"], - "财务流程": ["财务流程.docx"], - }.items(): - for name in names: - add_file_index(conn, "proposal", proposal_id, "v1.5", category, proposal_dir / name, external=True) - - projects = [ - ("圆心科技 科普文章项目", "v2026-文章", "execution", "SOP 执行中", "内容生产", 55, "文章内容生产与审核执行中"), - ("圆心科技 科普视频项目", "v2026-视频", "execution", "SOP 执行中", "内容生产", 45, "视频脚本、拍摄与审核推进"), - ("圆心科技 科普专访项目", "v2026-专访", "opportunity", "方案已提交", "商务推进", 0, "专访项目推动签约"), - ] - op_dir = WEIXIN_BASE / "3、运营方案" - for name, version, kind, status, stage, progress, note in projects: - 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), - ) - _exec(conn, - "INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)", - ("operation", cur.lastrowid, date.today().isoformat(), note, "补齐版本要求文件并更新下一节点"), - ) - - file_map = [ - (1, "v2026-文章", "项目方案", "圆心科技--科普文章项目(1).pptx"), - (2, "v2026-视频", "项目方案", "圆心科技-科普视频项目(1).pptx"), - (3, "v2026-专访", "项目方案", "圆心科技-科普专访项目-2026年(1).pdf"), - (1, "v2026-文章", "项目管理手册", "圆心科技《项目管理手册》-2026年.pdf"), - (2, "v2026-视频", "审核标准", "科普项目-审核标准(文章-视频-音频).pdf"), - ] - for project_id, version, category, filename in file_map: - add_file_index(conn, "operation", project_id, version, category, op_dir / filename, external=True) - - products = [ - ("妙手医生服务小程序", "v1.1", "视频任务增强 + 积分商城", "草稿箱、批量上传、积分商城、消息通知", "2026-Q3", "规划中", "科普平台"), - ("数字化营销后台管理系统", "v1.2", "运营数据看板 + 智能审核", "医生活跃、任务完成率、AI 预审、渠道数据上报", "2026-Q3", "设计中", "真研平台"), - ("妙手患者服务", "v0.5", "科普浏览 + 医生主页 MVP", "科普文章/视频浏览、医生主页、搜索", "2026-Q3", "规划中", "科普平台"), - ("数字人内容平台", "v0.1", "基础数字人视频生成 MVP", "预设形象、AI 配音、脚本驱动、简单模板", "2026-Q3", "规划中", "科普平台"), - ("渠道分发引擎", "v1.0", "六渠道统一分发", "分发 API、内容适配、分发排期、效果追踪", "2027-Q1", "规划中", "科普平台"), - ] - for product in products: - cur = _exec(conn, - "INSERT INTO product_versions (product_name,version,version_goal,feature_list,launch_date,status,platform) VALUES (?,?,?,?,?,?,?)", - product, - ) - _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]}", "按路线图推进"), - ) - - for month, record_type, category, amount, notes in [ - ("2026-05", "revenue", "信达生物续约确认收入", 120, "信达项目阶段确收"), - ("2026-06", "revenue", "信达生物续约确认收入", 80, "信达项目尾款预估"), - ("2026-05", "cost_expense", "内容生产", 32, "医生劳务与内容制作"), - ("2026-05", "cost_expense", "运营管理", 16, "项目管理与渠道协同"), - ("2026-06", "cost_expense", "渠道分发", 24, "投放与分发费用"), - ]: - _exec(conn, - "INSERT INTO finance_records (month,record_type,category,amount,occurred_date,notes) VALUES (?,?,?,?,?,?)", - (month, record_type, category, amount, f"{month}-01", notes), - ) - - # Seed project tasks for 信达科普文章项目 (project_id=1) - tasks_seed = [ - ("阶段1:渠道与商务确认", "商务对接", "合同签订", "Anna", "2026-06-30", "法务审核中", "合同签订后开始执行"), - ("阶段1:渠道与商务确认", "官媒渠道确认", "沟通官媒确定", "段丽华", "2026-06-30", "官媒尽力推,以先达成合作为准", "集团支持"), - ("阶段1:渠道与商务确认", "官媒渠道确认", "官媒合作签约", "段丽华", "2026-06-18", "", "官媒确认细节"), - ("阶段2:系统与标准搭建", "系统开发上线", "音频专访系统开发上线", "戴敏/梁军营", "2026-06-18", "客户比较着急执行,需要技术的资源", ""), - ("阶段2:系统与标准搭建", "系统开发上线", "精品视频系统开发上线", "戴敏/梁军营", "2026-06-25", "", ""), - ("阶段2:系统与标准搭建", "标准与培训", "业务执行手册SOP", "胡龙飞", "2026-06-12", "", "系统开发上线"), - ("阶段3:人员与审核入驻", "团队组建", "医学审核人员到位", "胡龙飞", "2026-06-15", "", "审核人员招聘"), - ("阶段3:人员与审核入驻", "团队组建", "视频制作人员到位", "胡龙飞", "2026-06-18", "", "项目经理招聘"), - ("阶段4:供应链与制作", "供应商准入", "准入拍摄/剪辑/主持人", "胡龙飞/侯亚凤", "2026-06-18", "", ""), - ("阶段2:系统与标准搭建", "脚本生产及审核", "生产脚本", "军营", "2026-06-12", "脚本目前生产比较机械,需要提前准备", "细分标签领域完成"), - ] - for phase, milestone, task, owner, due_date, blockers, notes in tasks_seed: - _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), - ) - - conn.commit() conn.close() + conn.close() + + +def seed_db(): + """填充初始示例数据(仅在空库时执行一次)""" + conn = db() + try: + if one(conn, "SELECT id FROM sales_leads LIMIT 1"): + return # 已有数据,跳过 + + sales = [ + ("齐鲁制药", "P0", "跟进中", "多产品线科普年度框架,需推进高层沟通。"), + ("百利天恒", "P0", "方案中", "BL-B01D1 上市前医生教育机会,准备方案。"), + ("信达生物", "P0", "已签约", "现有科普项目升级/续约,重点保障执行。"), + ("三生制药", "P1", "待跟进", "多科室医生教育+患者科普机会。"), + ("天广实生物", "P1", "待跟进", "血液肿瘤医生教育机会。"), + ] + for customer, priority, status, note in sales: + cur = _exec(conn, + "INSERT INTO sales_leads (target_customer, priority, status) VALUES (?,?,?)", + (customer, priority, status), + ) + _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 = _exec(conn, + "INSERT INTO business_proposals (customer_or_project_name,version,description,status,created_date) VALUES (?,?,?,?,?)", + ("信达生物", "v1.5", "信达科普项目续约与报价方案", "已提交客户", "2026-05-28"), + ) + proposal_id = cur.lastrowid + proposal_dir = WEIXIN_BASE / "2、业务方案/信达/v1.5" + for category, names in { + "方案": ["整体方案.pptx", "整体方案.pdf"], + "成本": ["业务报价-2亿方案.xlsx", "业务报价-5250万方案.xlsx", "5、最新报价.xlsx"], + "SOP": ["SOP.docx"], + "财务流程": ["财务流程.docx"], + }.items(): + for name in names: + add_file_index(conn, "proposal", proposal_id, "v1.5", category, proposal_dir / name, external=True) + + projects = [ + ("圆心科技 科普文章项目", "v2026-文章", "execution", "SOP 执行中", "内容生产", 55, "文章内容生产与审核执行中"), + ("圆心科技 科普视频项目", "v2026-视频", "execution", "SOP 执行中", "内容生产", 45, "视频脚本、拍摄与审核推进"), + ("圆心科技 科普专访项目", "v2026-专访", "opportunity", "方案已提交", "商务推进", 0, "专访项目推动签约"), + ] + op_dir = WEIXIN_BASE / "3、运营方案" + for name, version, kind, status, stage, progress, note in projects: + 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), + ) + _exec(conn, + "INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)", + ("operation", cur.lastrowid, date.today().isoformat(), note, "补齐版本要求文件并更新下一节点"), + ) + + file_map = [ + (1, "v2026-文章", "项目方案", "圆心科技--科普文章项目(1).pptx"), + (2, "v2026-视频", "项目方案", "圆心科技-科普视频项目(1).pptx"), + (3, "v2026-专访", "项目方案", "圆心科技-科普专访项目-2026年(1).pdf"), + (1, "v2026-文章", "项目管理手册", "圆心科技《项目管理手册》-2026年.pdf"), + (2, "v2026-视频", "审核标准", "科普项目-审核标准(文章-视频-音频).pdf"), + ] + for project_id, version, category, filename in file_map: + add_file_index(conn, "operation", project_id, version, category, op_dir / filename, external=True) + + products = [ + ("妙手医生服务小程序", "v1.1", "视频任务增强 + 积分商城", "草稿箱、批量上传、积分商城、消息通知", "2026-Q3", "规划中", "科普平台"), + ("数字化营销后台管理系统", "v1.2", "运营数据看板 + 智能审核", "医生活跃、任务完成率、AI 预审、渠道数据上报", "2026-Q3", "设计中", "真研平台"), + ("妙手患者服务", "v0.5", "科普浏览 + 医生主页 MVP", "科普文章/视频浏览、医生主页、搜索", "2026-Q3", "规划中", "科普平台"), + ("数字人内容平台", "v0.1", "基础数字人视频生成 MVP", "预设形象、AI 配音、脚本驱动、简单模板", "2026-Q3", "规划中", "科普平台"), + ("渠道分发引擎", "v1.0", "六渠道统一分发", "分发 API、内容适配、分发排期、效果追踪", "2027-Q1", "规划中", "科普平台"), + ] + for product in products: + cur = _exec(conn, + "INSERT INTO product_versions (product_name,version,version_goal,feature_list,launch_date,status,platform) VALUES (?,?,?,?,?,?,?)", + product, + ) + _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]}", "按路线图推进"), + ) + + for month, record_type, category, amount, notes in [ + ("2026-05", "revenue", "信达生物续约确认收入", 120, "信达项目阶段确收"), + ("2026-06", "revenue", "信达生物续约确认收入", 80, "信达项目尾款预估"), + ("2026-05", "cost_expense", "内容生产", 32, "医生劳务与内容制作"), + ("2026-05", "cost_expense", "运营管理", 16, "项目管理与渠道协同"), + ("2026-06", "cost_expense", "渠道分发", 24, "投放与分发费用"), + ]: + _exec(conn, + "INSERT INTO finance_records (month,record_type,category,amount,occurred_date,notes) VALUES (?,?,?,?,?,?)", + (month, record_type, category, amount, f"{month}-01", notes), + ) + + tasks_seed = [ + ("阶段1:渠道与商务确认", "商务对接", "合同签订", "Anna", "2026-06-30", "法务审核中", "合同签订后开始执行"), + ("阶段1:渠道与商务确认", "官媒渠道确认", "沟通官媒确定", "段丽华", "2026-06-30", "官媒尽力推,以先达成合作为准", "集团支持"), + ("阶段1:渠道与商务确认", "官媒渠道确认", "官媒合作签约", "段丽华", "2026-06-18", "", "官媒确认细节"), + ("阶段2:系统与标准搭建", "系统开发上线", "音频专访系统开发上线", "戴敏/梁军营", "2026-06-18", "客户比较着急执行,需要技术的资源", ""), + ("阶段2:系统与标准搭建", "系统开发上线", "精品视频系统开发上线", "戴敏/梁军营", "2026-06-25", "", ""), + ("阶段2:系统与标准搭建", "标准与培训", "业务执行手册SOP", "胡龙飞", "2026-06-12", "", "系统开发上线"), + ("阶段3:人员与审核入驻", "团队组建", "医学审核人员到位", "胡龙飞", "2026-06-15", "", "审核人员招聘"), + ("阶段3:人员与审核入驻", "团队组建", "视频制作人员到位", "胡龙飞", "2026-06-18", "", "项目经理招聘"), + ("阶段4:供应链与制作", "供应商准入", "准入拍摄/剪辑/主持人", "胡龙飞/侯亚凤", "2026-06-18", "", ""), + ("阶段2:系统与标准搭建", "脚本生产及审核", "生产脚本", "军营", "2026-06-12", "脚本目前生产比较机械,需要提前准备", "细分标签领域完成"), + ] + for phase, milestone, task, owner, due_date, blockers, notes in tasks_seed: + _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), + ) + + conn.commit() + logger.info("Seed data inserted successfully") + finally: + conn.close() def add_file_index(conn, module, owner_id, owner_version, category, path, external=True): path = Path(path) if not path.exists(): return - _exec(conn, + _exec(conn, """INSERT INTO file_assets (module,owner_id,owner_version,file_category,file_name,file_type,file_size,file_path,is_external) VALUES (?,?,?,?,?,?,?,?,?)""", @@ -480,48 +640,90 @@ def latest_followup(conn, target_type, target_id): def attach_common(conn, resource, items): + """批量加载 followups 和 files,避免 N+1 查询""" + if not items: + return items target_map = {"sales": "sales", "proposals": "proposal", "operations": "operation", "products": "product"} - for item in items: - if resource in target_map: - item["followups"] = rows( - conn, - "SELECT * FROM follow_up_records WHERE target_type=? AND target_id=? ORDER BY followed_at DESC, id DESC", - (target_map[resource], item["id"]), - ) + target_type = target_map.get(resource) + ids = [item["id"] for item in items] + + # 批量查 followups(一次性 IN 查询) + if target_type: + placeholders = ",".join(["?"] * len(ids)) + all_followups = rows( + conn, + f"SELECT * FROM follow_up_records WHERE target_type=? AND target_id IN ({placeholders}) ORDER BY followed_at DESC, id DESC", + [target_type] + ids, + ) + # 按目标 id 分组 + followups_by_id = {} + for fu in all_followups: + followups_by_id.setdefault(fu["target_id"], []).append(fu) + for item in items: + item["followups"] = followups_by_id.get(item["id"], []) item["latest_follow_up_record"] = item["followups"][0]["content"] if item["followups"] else "" - if resource == "proposals": - item["files"] = rows(conn, "SELECT * FROM file_assets WHERE module='proposal' AND owner_id=? ORDER BY id DESC", (item["id"],)) - if resource == "operations": - item["files"] = rows(conn, "SELECT * FROM file_assets WHERE module='operation' AND owner_id=? ORDER BY id DESC", (item["id"],)) + + # 批量查 files(proposals + operations) + file_modules = {"proposals": "proposal", "operations": "operation"} + if resource in file_modules: + module = file_modules[resource] + placeholders = ",".join(["?"] * len(ids)) + all_files = rows( + conn, + f"SELECT * FROM file_assets WHERE module=? AND owner_id IN ({placeholders}) ORDER BY id DESC", + [module] + ids, + ) + files_by_id = {} + for f in all_files: + files_by_id.setdefault(f["owner_id"], []).append(f) + for item in items: + item["files"] = files_by_id.get(item["id"], []) + return items def monthly_finance(conn, tenant="科普·无界"): - from datetime import date - today = date.today() - # 6 months: 3 before + current + 2 after - from dateutil.relativedelta import relativedelta - start = today + relativedelta(months=-3) - months = [] - for i in range(6): - m = start + relativedelta(months=i) - months.append(m.strftime("%Y-%m")) + months = [f"2026-{m:02d}" for m in range(1, 13)] + pfs = rows(conn, + "SELECT sign_amount, sign_month, status, budget_data FROM project_finances WHERE tenant=?", + [tenant]) + + # 预解析 budget_data:{pf_index: {month_key: {rev, gross, payment, cost}}} + parsed_budgets = [] + for pf in pfs: + try: + budget = json.loads(pf.get("budget_data") or "[]") + except (json.JSONDecodeError, TypeError): + budget = [] + budget_map = {} + for b in budget: + key = (b.get("month") or "").replace("-", "_") + budget_map[key] = { + "rev": float(b.get("rev") or 0), + "gross": float(b.get("gross") or 0), + "payment": float(b.get("payment") or 0), + "cost": float(b.get("cost") or 0), + } + parsed_budgets.append((pf, budget_map)) + data = [] for month in months: - col_month = month.replace("-", "_") - col_rev = f"rev_{col_month}" - col_gross = f"gross_{col_month}" - # Only project_finances has columns for 2026-06 through 2026-09 - if month in ["2026-06", "2026-07", "2026-08", "2026-09"]: - revenue = one(conn, f"SELECT COALESCE(SUM({col_rev}),0) AS v FROM project_finances WHERE tenant=?", (tenant,))["v"] - gross = one(conn, f"SELECT COALESCE(SUM({col_gross}),0) AS v FROM project_finances WHERE tenant=?", (tenant,))["v"] - else: - revenue = 0 - gross = 0 + key = month.replace("-", "_") + revenue = gross = payment = cost = sign = 0 + for pf, budget_map in parsed_budgets: + if pf["status"] == "已签约" and (pf.get("sign_month") or "") == month: + sign += float(pf["sign_amount"] or 0) + b = budget_map.get(key) + if b: + revenue += b["rev"] + gross += b["gross"] + payment += b["payment"] + cost += b["cost"] data.append({ "month": month, "revenue": revenue, "labor": 0, "expense": 0, "purchase": 0, "net_profit": gross, + "sign": sign, "payment": payment, "cost": cost, }) return data @@ -548,34 +750,49 @@ def bootstrap(): return rows(conn, sql, args) sales = attach_common(conn, "sales", q("SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", tenant)) proposals = attach_common(conn, "proposals", q("SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", tenant)) - operations = attach_common(conn, "operations", q("SELECT * FROM operation_projects WHERE tenant=? ORDER BY id DESC", tenant)) + operations = attach_common(conn, "operations", q("SELECT * FROM operation_projects WHERE tenant=? ORDER BY id ASC", tenant)) products = attach_common(conn, "products", q("SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", tenant)) finance = q("SELECT * FROM finance_records WHERE tenant=? ORDER BY month DESC, id DESC", tenant) tasks = q("SELECT * FROM project_tasks WHERE tenant=? ORDER BY phase, sort_order, id", tenant) pfs = q("SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", tenant) current_month = "2026-06" - # Finance aggregates — from project_finances (project-based) - def pf_sum(field): - return sum(x[field] or 0 for x in pfs) - rev_month = pf_sum("rev_2026_06") - gross_month = pf_sum("gross_2026_06") - rev_q2 = pf_sum("rev_2026_06") - gross_q2 = pf_sum("gross_2026_06") - rev_annual = rev_q2 - gross_annual = gross_q2 - # Contract aggregates — time-based - signed_amount = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_status"] == "已签约") - from datetime import date - today = date.today() - def contract_in_period(op, start, end): - if op["project_status"] != "已签约": return False + signed_pfs = [x for x in pfs if x["status"] == "已签约"] + + # 预解析 budget_data(避免重复 JSON 解析) + def parse_budget(pf): try: - d = date.fromisoformat(op["created_at"][:10]) - return start <= d <= end - except: return False - signed_annual = sum(x["expected_contract_amount"] or 0 for x in operations if contract_in_period(x, date(2026,1,1), date(2026,12,31))) - signed_q2 = sum(x["expected_contract_amount"] or 0 for x in operations if contract_in_period(x, date(2026,4,1), date(2026,6,30))) - signed_month = sum(x["expected_contract_amount"] or 0 for x in operations if contract_in_period(x, date(2026,6,1), date(2026,6,30))) + budget = json.loads(pf.get("budget_data") or "[]") + except (json.JSONDecodeError, TypeError): + budget = [] + return {(b.get("month") or "").replace("-", "_"): b for b in budget} + + budget_maps = [(pf, parse_budget(pf)) for pf in signed_pfs] + + def sum_budget(field, months_range): + total = 0 + for pf, bm in budget_maps: + for m in months_range: + b = bm.get(f"2026_{m:02d}") + if b: + total += float(b.get(field) or 0) + return total + + rev_annual = sum_budget("rev", range(1, 13)) + gross_annual = sum_budget("gross", range(1, 13)) + rev_q2 = sum_budget("rev", range(4, 7)) + gross_q2 = sum_budget("gross", range(4, 7)) + rev_month = sum_budget("rev", [6]) + gross_month = sum_budget("gross", [6]) + # Contract aggregates — from project_finances (经营管理项目) + def pf_status_sum(status): + return sum(x["sign_amount"] or 0 for x in pfs if x["status"] == status) + signed_amount = pf_status_sum("已签约") + # 年度签约 = 所有已签约项目 2026 年的签约金额 + signed_annual = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约") + # Q2 签约 = 签约月份在 2026-04~2026-06 的已签约项目 + signed_q2 = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in ["2026-04","2026-05","2026-06"]) + # 本月签约 = 签约月份为 2026-06 的已签约项目 + signed_month = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "") == "2026-06") pipeline_amount = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_status"] not in ["已签约","已丢单","已归档","已完成"]) signed_not_executed = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_type"] == "execution" and x["execution_progress"] < 100) summary = { @@ -588,10 +805,10 @@ def bootstrap(): "monthly_revenue": rev_month, "monthly_net_profit": gross_month, "monthly_gross": gross_month, - "upcoming_products": len([x for x in products if x["status"] in ["规划中", "设计中", "开发中", "测试中"]]), - "total_projects": len(operations), - "total_proposals": len(proposals), - "total_products": len(products), + "upcoming_products": len(products), + "total_projects": len(signed_pfs), + "total_proposals": len(operations), + "total_products": len(proposals), # Extended finance metrics "signed_amount": signed_amount, "signed_annual": signed_annual, @@ -619,7 +836,7 @@ TABLES = { "products": ("product_versions", ["product_name", "version", "version_goal", "feature_list", "launch_date", "status", "platform", "notes", "tenant"]), "finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes", "tenant"]), "tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "priority", "tenant"]), - "projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "total_rev", "total_gross", "budget_data"]), + "projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "owner", "total_rev", "total_gross", "budget_data"]), } @@ -630,9 +847,24 @@ def create_resource(resource): return jsonify({"error": "unknown resource"}), 404 table, cols = TABLES[resource] payload = request.get_json(force=True).get("data", {}) - values = [payload.get(col, "") for col in cols] + # 任务状态校验:空值或非法值修正为"未开始" + if resource == "tasks": + valid_statuses = ["未开始", "进行中", "已结束"] + if not payload.get("status") or payload["status"] not in valid_statuses: + payload["status"] = "未开始" conn = db() try: + # 获取列类型,数值列空字符串转 0 避免 MySQL 严格模式报错 + type_cur = conn.cursor() + type_cur.execute(f"DESCRIBE {table}") + col_types = {r[0]: r[1].upper() for r in type_cur.fetchall()} + type_cur.close() + values = [] + for col in cols: + val = payload.get(col, "") + if val == "" and ("DOUBLE" in col_types.get(col, "") or "INT" in col_types.get(col, "")): + val = 0 + values.append(val) cur = _exec(conn, f"INSERT INTO {table} ({','.join(cols)}) VALUES ({','.join(['?'] * len(cols))})", values) conn.commit() return jsonify({"id": cur.lastrowid}) @@ -653,9 +885,14 @@ def update_resource(resource, item_id): conn.commit() return jsonify({"ok": True}) payload = request.get_json(force=True).get("data", {}) + # 任务状态校验:空值或非法值修正为"未开始" + if resource == "tasks" and "status" in payload: + valid_statuses = ["未开始", "进行中", "已结束"] + if not payload["status"] or payload["status"] not in valid_statuses: + payload["status"] = "未开始" update_cols = [col for col in cols if col in payload] if update_cols: - _exec(conn, + _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], ) @@ -721,6 +958,20 @@ def batch_sort_tasks(): conn.close() +@app.route("/api/operations/batch-sort", methods=["POST"]) +@login_required +def batch_sort_operations(): + conn = db() + try: + items = request.get_json(force=True).get("items", []) + for item in items: + _exec(conn, "UPDATE operation_projects SET sort_order=? WHERE id=?", (item["sort_order"], item["id"])) + conn.commit() + return jsonify({"ok": True}) + finally: + conn.close() + + @app.route("/api/files/upload", methods=["POST"]) @login_required def upload_file(): @@ -782,7 +1033,12 @@ def health(): init_db() +seed_db() if __name__ == "__main__": - app.run(host="127.0.0.1", port=5177, debug=True) + app.run( + host="127.0.0.1", + port=5177, + debug=os.environ.get("FLASK_DEBUG", "false").lower() in ("true", "1", "yes"), + ) diff --git a/static/app.js b/static/app.js index f9af63f..e8cbfea 100644 --- a/static/app.js +++ b/static/app.js @@ -1,1466 +1,19 @@ -const state = { - active: "home", - data: null, - tenant: "科普·无界", - opFilter: "all", - finFilter: "已签约", - selectedProject: null, - taskQuery: "", - chart: null, - chart2: null, - productPlatform: "all", - uploadTasks: [], -}; - -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, { - headers: options.body instanceof FormData ? undefined : { "Content-Type": "application/json" }, - ...options, - }); - const data = await response.json(); - if (!response.ok) throw new Error(data.error || "请求失败"); - 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"; - if (["P0", "有风险", "已丢单", "已延期"].includes(val)) cls = "badge-red"; - if (["P1", "方案中", "方案已提交", "商务谈判", "待客户确认"].includes(val)) cls = "badge-amber"; - if (["已签约", "已上线", "已完成", "已归档"].includes(val)) cls = "badge-green"; - if (["execution", "已签约执行项目"].includes(val)) cls = "badge-blue"; - return `${val === "execution" ? "已签约执行项目" : val === "opportunity" ? "业务机会项目" : val}`; -} - -function card(content, cls = "") { - return `
${content}
`; -} - -function renderTable(headers, rows, rowClicks) { - const trAttrs = (rowClicks || []).map((rc) => rc ? `onclick="openDrawer('${rc.resource}', ${rc.id})" class="clickable-row"` : ""); - return card(` -
- - ${headers.map((h) => ``).join("")} - ${rows.map((row, i) => `${row.map((c) => ``).join("")}`).join("")} -
${h}
${c}
-
- `); -} - -async function load() { - state.data = await api(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`); - render(); -} - -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(); -} - -function render() { - if (!state.data) return; - renderHome(); - renderProjects(); - renderProposals(); - renderProducts(); - renderFinance(); - if (window.lucide) window.lucide.createIcons(); -} - -function renderHome() { - const { summary, financeMonthly } = state.data; - const m = summary.metrics; - const rows1 = [ - ["年度累计签约", money(m.signed_annual || m.signed_amount)], - ["Q2 累计签约", money(m.signed_q2 || 0)], - ["本月新增签约", money(m.signed_month || 0)], - ["合同流程中", money(m.pipeline_amount)], - ]; - const rows2 = [ - ["年度累计确收", money(m.revenue_annual)], - ["Q2 累计确收", money(m.revenue_q2)], - ["本月新增确收", money(m.monthly_revenue)], - ["已签约未执行", money(m.signed_not_executed)], - ]; - const rows3 = [ - ["年度累计毛利", money(m.gross_annual)], - ["Q2 累计毛利", money(m.gross_q2)], - ["本月新增毛利", money(m.monthly_net_profit)], - ["合同毛利率", m.revenue_annual ? Math.round(m.gross_annual / m.revenue_annual * 100) + "%" : "—"], - ]; - const tblCard = (title, rows) => card(`

${title}

${rows.map(([label, value]) => ``).join("")}
${label}${value}
`, "p-4"); - document.querySelector("#home").innerHTML = ` -
-
- ${[ - ["经营管理", 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)}
-
- ${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")} -
- `; - renderChart(financeMonthly); -} - -function renderChart(data) { - const canvas = document.querySelector("#financeChart"); - if (!canvas || !window.Chart) return; - if (state.chart) state.chart.destroy(); - state.chart = new Chart(canvas, { - type: "line", - data: { - labels: data.map((x) => x.month), - datasets: [ - { label: "收入", data: data.map((x) => x.revenue), borderColor: "#2563eb", tension: 0.3 }, - { label: "毛利", data: data.map((x) => x.gross_profit), borderColor: "#059669", tension: 0.3 }, - { label: "成本/费用", data: data.map((x) => x.cost_expense), borderColor: "#d97706", tension: 0.3 }, - { label: "净利", data: data.map((x) => x.net_profit), borderColor: "#7c3aed", 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 } } } } }, - }); -} - -function formHtml(fields, button) { - return `
- ${fields.map((f) => ``).join("")} - -
`; -} - -async function createResource(event, resource) { - event.preventDefault(); - const form = event.currentTarget; - const data = Object.fromEntries(new FormData(form).entries()); - data.tenant = state.tenant; - try { - 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) { - alert("创建失败:" + error.message); - } -} - -window.createSales = (event) => createResource(event, "sales"); -window.createProposal = (event) => createResource(event, "proposals"); -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) { - document.querySelector(`#task-id-${projectId}`).value = ""; - document.querySelector(`#task-name-${projectId}`).value = ""; - document.querySelector(`#task-phase-${projectId}`).value = "商务洽谈"; - document.querySelector(`#task-owner-${projectId}`).value = ""; - document.querySelector(`#task-due-${projectId}`).value = ""; - document.querySelector(`#task-notes-${projectId}`).value = ""; - document.querySelector(`#task-blockers-${projectId}`).value = ""; - document.querySelector(`#task-priority-${projectId}`).value = "P2"; - document.querySelector(`#task-status-${projectId}`).value = "未开始"; - document.querySelector(`#task-submit-btn-${projectId}`).textContent = "确认新增"; - if (titleEl) titleEl.textContent = "新增任务"; - } else { - const task = (state.data.tasks || []).find((t) => t.id === taskId); - if (!task) return; - document.querySelector(`#task-id-${projectId}`).value = task.id; - document.querySelector(`#task-name-${projectId}`).value = task.task || ""; - document.querySelector(`#task-phase-${projectId}`).value = task.phase || "商务洽谈"; - document.querySelector(`#task-owner-${projectId}`).value = task.owner || ""; - document.querySelector(`#task-due-${projectId}`).value = task.due_date || ""; - document.querySelector(`#task-notes-${projectId}`).value = task.notes || ""; - document.querySelector(`#task-blockers-${projectId}`).value = task.blockers || ""; - document.querySelector(`#task-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 = "编辑任务"; - } - drawer.classList.add("open"); -}; -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 { - 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(); - } - } catch (error) { - alert("保存失败:" + error.message); - } -}; -window.createFinance = async (event) => { - event.preventDefault(); - const form = event.currentTarget; - const data = Object.fromEntries(new FormData(form).entries()); - data.tenant = state.tenant; - data.sign_amount = parseFloat(data.sign_amount) || 0; - 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 { - 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 = ""; - document.querySelector("#financeModalTitle").textContent = "新增项目财务"; - closeFinanceModal(); - await load(); - } catch (error) { - alert("保存失败:" + error.message); - } -}; -window.switchTab = switchTab; -window.switchTenant = (tenant) => { - state.tenant = tenant; - state.selectedProject = null; - localStorage.setItem("opc-active-tenant", tenant); - const label = tenant.replace("·无界", ""); - document.querySelector("#workspaceTitle").textContent = label + " OPC 工作台"; - document.querySelectorAll(".workspace-nav-item").forEach(el => el.classList.toggle("active", el.dataset.tenant === tenant)); - load(); -}; - -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; - // 默认选中第一个项目 - 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.selectedProject = null; renderProjects(); return; } - const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId); - 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 = `
编辑任务
-
- - - - - - - - - -
- - -
-
`; - 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 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) => { - 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("")} -
-
-
编辑任务
-
- - - - - - - -
- - -
-
-
-
`; - document.querySelector("#taskModal").classList.add("active"); - if (window.lucide) window.lucide.createIcons(); -} -window.closeTaskModal = () => { - document.querySelector("#taskModal").classList.remove("active"); - document.querySelector("#taskModal").innerHTML = ""; -}; - -function renderProposals() { - const 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 = `
-
- -
- ${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}

-
${files.length ? files.map(fileItem).join("") : `

暂无文件

`}
-
`; -} - -function fileItem(file) { - return `

${file.file_name}

`; -} - -window.deleteFile = async (fileId) => { - if (!confirm("确认删除此文件?")) return; - await api(`/api/files/${fileId}`, { method: "DELETE" }); - // 从本地 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 = (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); - - 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.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 = `
-
-

产品版本

- -
-
- - - - - - - - - - ${productRows} -
状态产品名称版本号版本目标核心功能上线日期
-
-
- - `; -} - -function renderFinance() { - const pfs = state.data.projectFinances || []; - const ops = state.data.operations || []; - const fmTypesByTenant = { - "科普·无界": ["科普音频","科普视频","科普文章","全品类科普"], - "科研·无界": ["真实世界研究","调研问卷","病例征集","患者招募"], - "医患·无界": ["医患运营","患者管理","患教会","创新支付","电商","其他"], - }; - 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 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 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] || 0; - const gross = pf["gross_"+m] || 0; - return `${rev ? money(rev) : '—'}
${gross ? money(gross) : '—'}`; - }).join(""); - const totalRev = pf.total_rev || 0; - const totalGross = pf.total_gross || 0; - const totalCol = `${totalRev ? money(totalRev) : '—'}
${totalGross ? money(totalGross) : '—'}`; - 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)],["流程项目","" + 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(`

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

${[["已签约","已签约"],["流程中","流程中"],["待签约","待签约"]].map(([k,v]) => ``).join("")}
${monthLabels.map(l => ``).join("")}${pfs.filter(x => x.status === state.finFilter).map(renderPfRow).join("")}
客户类型状态签约月份签约金额${l}
确收/毛利
总计
确收/毛利
销售
`, "p-4")} -
`; -} -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 || ""; - const signMonthValue = pf.sign_month || ""; - const signMonthEl = form.querySelector('[name="sign_month"]'); - if (signMonthEl && signMonthValue) { - signMonthEl.innerHTML = monthOptions(signMonthValue); - signMonthEl.value = signMonthValue; - } - 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(); -}; -function renderChartOn(id, data) { - const canvas = document.querySelector(`#${id}`); - if (!canvas || !window.Chart) return; - if (state.chart2) state.chart2.destroy(); - state.chart2 = new Chart(canvas, { - type: "line", - data: { - labels: data.map((x) => x.month), - datasets: [ - { label: "收入", data: data.map((x) => x.revenue), borderColor: "#2563eb", tension: 0.3 }, - { label: "毛利", data: data.map((x) => x.gross_profit), borderColor: "#059669", tension: 0.3 }, - { label: "成本/费用", data: data.map((x) => x.cost_expense), borderColor: "#d97706", tension: 0.3 }, - { label: "净利", data: data.map((x) => x.net_profit), borderColor: "#7c3aed", 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 } } } } }, - }); -} - -function drawerField(icon, label, name, value, multiline = false, customControl = null) { - const initialValue = text(value); - const control = customControl - ? customControl - : multiline - ? `` - : ``; - return `
-
${label}
-
${control}
-
`; -} - -function openDrawer(resource, id) { - const list = resource === "sales" ? state.data.sales : resource === "operations" ? state.data.operations : resource === "proposals" ? state.data.proposals : state.data.products; - const item = list.find((x) => x.id === id); - const drawer = document.querySelector("#drawer"); - const fields = resource === "sales" - ? [["target_customer","业务机会"],["priority","优先级"],["status","状态"]] - : resource === "operations" - ? [["project_name","项目名称"],["owner","负责人"],["expected_sign_date","截止时间"],["expected_contract_amount","金额"],["notes","项目说明"]] - : resource === "proposals" - ? [["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", - owner: "user", customer_need: "file-text", expected_contract_amount: "banknote", expected_sign_date: "calendar", - sign_probability: "percent", sop_stage: "list-checks", execution_progress: "activity", - current_deliverable: "package", risks: "alert-triangle", next_action: "arrow-right", - product_name: "box", version: "tag", version_goal: "target", feature_list: "list", platform: "layers", - 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.customer_or_project_name} · ${item.proposal_type || ''}` : "") || item.product_name; - drawer.innerHTML = `

Detail Drawer

${title}

-
-

属性

-
- ${drawerField("map-pin", "当前阶段", "current_stage", "", false, ``)} - ${fields.map(([key,label]) => drawerField(fieldIcons[key] || "circle", label, key, item[key], multilineFields.includes(key))).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("")}
-
-
- - - - - - - - - - - -
-
-
- 支持富文本编辑 - -
-
-
` : ""} -
-
`; - 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; - 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 setDrawerSaveStatus(message, tone = "muted") { - const el = document.querySelector("#drawerSaveStatus"); - if (!el) return; - el.textContent = message; - el.dataset.tone = tone; -} - -function bindDrawerAutosave(resource, id, item) { - document.querySelectorAll("#drawerForm .drawer-value").forEach((field) => { - field.addEventListener("keydown", (event) => { - if (event.key === "Enter" && field.tagName !== "TEXTAREA") field.blur(); - }); - const doSave = async () => { - const value = field.value; - if (value === field.dataset.original) return; - const previous = field.dataset.original; - field.dataset.original = value; - setDrawerSaveStatus("保存中…"); - try { - await api(`/api/${resource}/${id}`, { method: "PUT", body: JSON.stringify({ data: { [field.name]: value } }) }); - item[field.name] = value; - const titleValue = item.target_customer || item.project_name || item.customer_or_project_name || item.product_name; - const titleEl = document.querySelector(".drawer-title"); - if (titleEl) titleEl.textContent = titleValue; - render(); - setDrawerSaveStatus("已保存", "success"); - setTimeout(() => setDrawerSaveStatus(""), 1200); - } catch (error) { - field.dataset.original = previous; - setDrawerSaveStatus("保存失败", "danger"); - alert(`自动保存失败:${error.message}`); - } - }; - field.addEventListener("blur", doSave); - if (field.tagName === "SELECT") field.addEventListener("change", doSave); - }); -} - - - -window.openDrawer = openDrawer; -window.deleteDrawerItem = async (resource, id) => { - if (!confirm("确认删除?此操作不可撤销。")) return; - try { - // 记录删除前的名称用于活动日志 - 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.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 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 } }) }); - 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); - } -}; -window.deleteTask = async (projectId) => { - const taskId = document.querySelector(`#task-id-${projectId}`).value; - 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); - 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); - } -}; -let dragTaskId = null; -window.handleTaskDragStart = (event, taskId) => { - dragTaskId = taskId; - event.currentTarget.classList.add("dragging"); - event.dataTransfer.effectAllowed = "move"; -}; -window.handleTaskDrop = async (event, projectId, phase) => { - event.preventDefault(); - event.currentTarget.classList.remove("drag-over"); - const target = event.currentTarget; - if (!dragTaskId) return; - // Find the dragged element and insert after the nearest task - const dragged = document.querySelector(`.task-item[data-id="${dragTaskId}"]`); - if (!dragged) return; - const afterElement = getDragAfterElement(target, event.clientY); - if (afterElement) { - target.insertBefore(dragged, afterElement); - } else { - target.appendChild(dragged); - } - dragged.classList.remove("dragging"); - // Update sort_order in DB - const rows = [...target.querySelectorAll(".task-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 }) }); - } catch (e) { /* non-critical */ } - dragTaskId = null; -}; -function getDragAfterElement(container, y) { - 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; - if (offset < 0 && offset > closest.offset) { - return { offset, element: child }; - } - return closest; - }, { offset: Number.NEGATIVE_INFINITY }).element; -} -window.closeDrawer = () => document.querySelector("#drawer").classList.remove("open"); -window.squireInstances = {}; -window.squireCmd = (cmd) => { - const currentEditor = document.querySelector(".squire-editor"); - if (!currentEditor) return; - const id = currentEditor.id; - const sq = window.squireInstances[id]; - if (!sq) return; - sq.focus(); - setTimeout(() => { - if (cmd === "bold") { - sq.hasFormat("b") || sq.hasFormat("strong") ? sq.removeBold() : sq.bold(); - } else if (cmd === "italic") { - sq.hasFormat("i") || sq.hasFormat("em") ? sq.removeItalic() : sq.italic(); - } else if (cmd === "underline") { - sq.hasFormat("u") ? sq.changeFormat(null, { tag: "u" }, null) : sq.changeFormat({ tag: "u" }, null, null); - } else if (cmd === "strikethrough") { - sq.hasFormat("s") || sq.hasFormat("del") || sq.hasFormat("strike") ? sq.changeFormat(null, { tag: "s" }, null) : sq.changeFormat({ tag: "s" }, null, null); - } else { - sq[cmd](); - } - }, 10); -}; - -window.submitComment = async (event, targetType, targetId, resource) => { - event.preventDefault(); - const form = event.currentTarget; - const editorDiv = form.querySelector(".squire-editor"); - const sq = window.squireInstances[editorDiv.id]; - const content = sq ? sq.getHTML().trim() : ""; - if (!content || content === "

" || content === "


") return; - const button = form.querySelector(".comment-submit"); - button.disabled = true; - button.textContent = "发送中…"; - await api(`/api/followups/${targetType}/${targetId}`, { method: "POST", body: JSON.stringify({ data: { content } }) }); - await load(); - 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; - await api(`/api/followups/${followupId}`, { method: "DELETE" }); - await load(); - openDrawer(resource, targetId); -}; - +// app.js — 入口文件(加载模块 + 初始化) +// 所有业务逻辑已拆分到 modules/ 目录: +// utils.js — 共享状态、工具函数、API 封装 +// home.js — 首页 + 财务趋势图 +// projects.js — 重点工作与台账(项目+任务+拖拽) +// proposals.js — 业务方案 + 文件管理 +// products.js — 产品迭代 +// finance.js — 经营管理(财务) +// drawer.js — 详情抽屉 + 评论 + 转移 + +// Tab 点击委托 document.querySelector("#tabs").addEventListener("click", (event) => { const button = event.target.closest("button[data-tab]"); if (button) switchTab(button.dataset.tab); }); + // 恢复上次的工作台和标签页 const savedTenant = localStorage.getItem("opc-active-tenant"); if (savedTenant) { @@ -1471,8 +24,10 @@ if (savedTenant) { } const savedTab = localStorage.getItem("opc-active-tab"); +// 初始化 +applyUserTenants(); load().then(() => { if (savedTab && savedTab !== "home") switchTab(savedTab); }).catch((error) => { - document.querySelector("main").innerHTML = `
加载失败:${error.message}
`; + document.querySelector("main").innerHTML = `
加载失败:${esc(error.message)}
`; }); diff --git a/static/modules/admin.js b/static/modules/admin.js new file mode 100644 index 0000000..b2dbc32 --- /dev/null +++ b/static/modules/admin.js @@ -0,0 +1,168 @@ +// admin.js — 账号管理(仅 admin 可见) + +window.openAdminUsers = async () => { + const overlay = document.createElement("div"); + overlay.id = "adminOverlay"; + overlay.className = "fixed inset-0 bg-black/40 z-[9998] flex items-center justify-center p-4"; + overlay.innerHTML = ` +
+
+

账号管理

+
+ + +
+
+
+
`; + overlay.addEventListener("click", (e) => { if (e.target === overlay) closeAdminUsers(); }); + document.body.appendChild(overlay); + if (window.lucide) lucide.createIcons(); + await loadUserList(); +}; + +window.closeAdminUsers = () => { + const el = document.getElementById("adminOverlay"); + if (el) el.remove(); +}; + +async function loadUserList() { + const list = document.getElementById("adminUserList"); + if (!list) return; + list.innerHTML = `
加载中...
`; + try { + const users = await api("/api/users"); + const tenants = await api("/api/tenants"); + list.innerHTML = ` + + + + + + + + + + + + ${users.map(u => ` + + + + + + + + `).join('')} + +
用户名显示名角色工作台操作
${esc(u.username)}${esc(u.display_name)} + ${u.role === 'admin' ? '管理员' : 'OPC负责人'} + ${(u.tenants || []).map(t => `${esc(t)}`).join('') || ''} + + +
`; + if (window.lucide) lucide.createIcons(); + } catch (e) { + list.innerHTML = `
加载失败:${esc(e.message)}
`; + } +} + +window.openUserForm = async (uid) => { + let user = null; + let userTenants = []; + if (uid) { + try { + const users = await api("/api/users"); + user = users.find(u => u.id === uid); + userTenants = user?.tenants || []; + } catch (e) { toast("加载用户失败", "error"); return; } + } + const tenants = await api("/api/tenants"); + + const modal = document.createElement("div"); + modal.id = "userFormModal"; + modal.className = "fixed inset-0 bg-black/40 z-[9999] flex items-center justify-center p-4"; + modal.innerHTML = ` +
+
+

${user ? '编辑账号' : '新增账号'}

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ ${tenants.map(t => ` + + `).join('')} +
+
+
+ + +
+
+
`; + modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); }); + document.body.appendChild(modal); + if (window.lucide) lucide.createIcons(); +}; + +window.submitUserForm = async (event, uid) => { + event.preventDefault(); + const form = event.target; + const fd = new FormData(form); + const payload = { + username: fd.get("username"), + display_name: fd.get("display_name"), + password: fd.get("password"), + role: fd.get("role"), + tenants: fd.getAll("tenants"), + }; + try { + if (uid) { + await api(`/api/users/${uid}`, { method: "PUT", body: JSON.stringify(payload) }); + toast("已更新", "success"); + } else { + await api("/api/users", { method: "POST", body: JSON.stringify(payload) }); + toast("已新增", "success"); + } + document.getElementById("userFormModal").remove(); + await loadUserList(); + } catch (e) { + toast("保存失败:" + e.message, "error"); + } +}; + +window.deleteUser = async (uid, username) => { + if (!confirm(`确认删除账号「${username}」?此操作不可撤销。`)) return; + try { + await api(`/api/users/${uid}`, { method: "DELETE" }); + toast("已删除", "success"); + await loadUserList(); + } catch (e) { + toast("删除失败:" + e.message, "error"); + } +}; diff --git a/static/modules/drawer.js b/static/modules/drawer.js new file mode 100644 index 0000000..68663fb --- /dev/null +++ b/static/modules/drawer.js @@ -0,0 +1,268 @@ +// drawer.js — 详情抽屉 + 评论 + 转移 + 删除 + +function drawerField(icon, label, name, value, multiline = false, customControl = null) { + const safeValue = esc(value || ""); + const control = customControl + ? customControl + : multiline + ? `` + : ``; + return `
+
${label}
+
${control}
+
`; +} + +function openDrawer(resource, id) { + const list = resource === "sales" ? state.data.sales : resource === "operations" ? state.data.operations : resource === "proposals" ? state.data.proposals : state.data.products; + const item = list.find((x) => x.id === id); + const drawer = document.querySelector("#drawer"); + const fields = resource === "sales" + ? [["target_customer","业务机会"],["priority","优先级"],["status","状态"]] + : resource === "operations" + ? [["project_name","项目名称"],["owner","负责人"],["expected_sign_date","截止时间"],["expected_contract_amount","金额"],["notes","项目说明"]] + : resource === "proposals" + ? [["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", + owner: "user", customer_need: "file-text", expected_contract_amount: "banknote", expected_sign_date: "calendar", + sign_probability: "percent", sop_stage: "list-checks", execution_progress: "activity", + current_deliverable: "package", risks: "alert-triangle", next_action: "arrow-right", + product_name: "box", version: "tag", version_goal: "target", feature_list: "list", platform: "layers", + 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 = esc(item.target_customer || item.project_name || (item.customer_or_project_name ? `${item.customer_or_project_name} · ${item.proposal_type || ''}` : "") || item.product_name); + const titleForAttr = esc(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}

+
+

属性

+
+ ${resource === "operations" ? drawerField("map-pin", "当前阶段", "current_stage", "", false, ``) : ""} + ${fields.map(([key,label]) => { + if (resource === "products" && key === "feature_list") { + const features = (item[key] || "").split("\n").filter(Boolean); + if (features.length === 0) features.push(""); + return `
${label}
${features.map((f,i) => `
${i+1}.
`).join("")}
`; + } + if (resource === "products" && key === "launch_date") { + return drawerField("calendar", label, key, item[key], false, ``); + } + if (resource === "products" && key === "status") { + return drawerField("circle-dot", label, key, "", false, ``); + } + return drawerField(fieldIcons[key] || "circle", label, key, item[key], multilineFields.includes(key)); + }).join("")} +
+
+ ${resource === "proposals" ? `

附件

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

活动 / 跟进

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

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

` : ""}
`).join("")}
+
+
+ + + + + + + + + + + +
+
+
+ 支持富文本编辑 + +
+
+
` : ""} +
+
`; + drawer.classList.add("open"); + bindDrawerAutosave(resource, item.id, item); + if (window.lucide) window.lucide.createIcons(); + renderUploadTasks(); + drawer.querySelectorAll(".rich-content").forEach((el) => { + const html = el.dataset.html; + if (html) el.innerHTML = decodeURIComponent(html); + }); + 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; + squireDiv.addEventListener("focus", () => squireDiv.classList.add("focused")); + squireDiv.addEventListener("blur", () => { + if (!squireDiv.textContent.trim()) squireDiv.classList.remove("focused"); + }); + } +} + +function setDrawerSaveStatus(message, tone = "muted") { + const el = document.querySelector("#drawerSaveStatus"); + if (!el) return; + el.textContent = message; + el.dataset.tone = tone; +} + +function bindDrawerAutosave(resource, id, item) { + document.querySelectorAll("#drawerForm .form-ctrl").forEach((field) => { + field.addEventListener("keydown", (event) => { + if (event.key === "Enter" && field.tagName !== "TEXTAREA") field.blur(); + }); + const doSave = async () => { + const value = field.value; + if (value === field.dataset.original) return; + const previous = field.dataset.original; + field.dataset.original = value; + setDrawerSaveStatus("保存中…"); + try { + await api(`/api/${resource}/${id}`, { method: "PUT", body: JSON.stringify({ data: { [field.name]: value } }) }); + item[field.name] = value; + const titleValue = item.target_customer || item.project_name || item.customer_or_project_name || item.product_name; + const titleEl = document.querySelector(".drawer-title"); + if (titleEl) titleEl.textContent = titleValue; + renderActive(); + setDrawerSaveStatus("已保存", "success"); + setTimeout(() => setDrawerSaveStatus(""), 1200); + } catch (error) { + field.dataset.original = previous; + setDrawerSaveStatus("保存失败", "danger"); + toast(`自动保存失败:${error.message}`, "error"); + } + }; + field.addEventListener("blur", doSave); + if (field.tagName === "SELECT") field.addEventListener("change", doSave); + }); +} + +window.openDrawer = openDrawer; +window.closeDrawer = () => document.querySelector("#drawer").classList.remove("open"); + +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) { + toast("删除失败:" + error.message, "error"); + } +}; + +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) { + toast("转移失败:" + error.message, "error"); + } +}; + +// Squire 富文本编辑器 +window.squireInstances = {}; +window.squireCmd = (cmd) => { + const currentEditor = document.querySelector(".squire-editor"); + if (!currentEditor) return; + const id = currentEditor.id; + const sq = window.squireInstances[id]; + if (!sq) return; + sq.focus(); + setTimeout(() => { + if (cmd === "bold") { + sq.hasFormat("b") || sq.hasFormat("strong") ? sq.removeBold() : sq.bold(); + } else if (cmd === "italic") { + sq.hasFormat("i") || sq.hasFormat("em") ? sq.removeItalic() : sq.italic(); + } else if (cmd === "underline") { + sq.hasFormat("u") ? sq.changeFormat(null, { tag: "u" }, null) : sq.changeFormat({ tag: "u" }, null, null); + } else if (cmd === "strikethrough") { + sq.hasFormat("s") || sq.hasFormat("del") || sq.hasFormat("strike") ? sq.changeFormat(null, { tag: "s" }, null) : sq.changeFormat({ tag: "s" }, null, null); + } else { + sq[cmd](); + } + }, 10); +}; + +window.submitComment = async (event, targetType, targetId, resource) => { + event.preventDefault(); + const form = event.currentTarget; + const editorDiv = form.querySelector(".squire-editor"); + const sq = window.squireInstances[editorDiv.id]; + const content = sq ? sq.getHTML().trim() : ""; + if (!content || content === "

" || content === "


") return; + const button = form.querySelector(".comment-submit"); + button.disabled = true; + button.textContent = "发送中…"; + await api(`/api/followups/${targetType}/${targetId}`, { method: "POST", body: JSON.stringify({ data: { content } }) }); + await load(); + 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; + await api(`/api/followups/${followupId}`, { method: "DELETE" }); + await load(); + openDrawer(resource, targetId); +}; + +window.saveDrawerField = async (el, resource, id) => { + const name = el.name; + const value = el.value; + try { + await api(`/api/${resource}/${id}`, { method: "PUT", body: JSON.stringify({ data: { [name]: value } }) }); + const listKey = { sales: "sales", proposals: "proposals", operations: "operations", products: "products" }[resource]; + if (listKey && state.data[listKey]) { + const item = state.data[listKey].find(x => x.id === id); + if (item) item[name] = value; + } + } catch (error) { + toast("保存失败:" + error.message, "error"); + } +}; diff --git a/static/modules/finance.js b/static/modules/finance.js new file mode 100644 index 0000000..658ed06 --- /dev/null +++ b/static/modules/finance.js @@ -0,0 +1,366 @@ +// finance.js — 经营管理(财务)模块 + +function renderFinance() { + const pfs = state.data.projectFinances || []; + const ops = state.data.operations || []; + const fmTypesByTenant = { + "科普·无界": ["科普音频","科普视频","科普文章","全品类科普","调研问卷"], + "科研·无界": ["真实世界研究","调研问卷","病例征集","患者招募"], + "医患·无界": ["医患运营","患者管理","患教会","创新支付","电商","其他"], + }; + 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 now = new Date(); + const thisMonth = now.getMonth() + 1; + 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); + + 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 => { + return signed.reduce((s, pf) => { + let budget = []; + try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {} + const row = budget.find(b => (b.month || "").replace("-", "_") === m); + return s + (row ? (parseFloat(row.rev) || 0) : 0); + }, 0); + }); + const monthGross = months.map(m => { + return signed.reduce((s, pf) => { + let budget = []; + try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {} + const row = budget.find(b => (b.month || "").replace("-", "_") === m); + return s + (row ? (parseFloat(row.gross) || 0) : 0); + }, 0); + }); + + const thisMonthKey = displayMonths[0].key; + 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) => { + let budgetMap = {}; + try { + const budget = JSON.parse(pf.budget_data || "[]"); + budget.forEach(b => { budgetMap[(b.month || "").replace("-", "_")] = b; }); + } catch (e) {} + const isRevView = state.finView !== "cashflow"; + const mCols = months.map(m => { + const b = budgetMap[m] || {}; + if (isRevView) { + const rev = b.rev || 0; + const gross = b.gross || 0; + return `${rev ? money(rev) : '—'}
${gross ? money(gross) : '—'}`; + } else { + const payment = b.payment || 0; + const cost = b.cost || 0; + return `${payment ? money(payment) : '—'}
${cost ? money(cost) : '—'}`; + } + }).join(""); + const totalCol = (() => { + if (isRevView) { + const totalRev = pf.total_rev || 0; + const totalGross = pf.total_gross || 0; + return `${totalRev ? money(totalRev) : '—'}
${totalGross ? money(totalGross) : '—'}`; + } else { + let totalPayment = 0, totalCost = 0; + try { JSON.parse(pf.budget_data || "[]").forEach(b => { totalPayment += parseFloat(b.payment||0)||0; totalCost += parseFloat(b.cost||0)||0; }); } catch (e) {} + return `${totalPayment ? money(totalPayment) : '—'}
${totalCost ? money(totalCost) : '—'}`; + } + })(); + const sm = pf.sign_month || ""; + const signMonthCell = `${sm || '—'}`; + return `${esc(pf.customer_name)}${esc(pf.business_type)}${pf.status === "已签约" ? badge("已签约") : pf.status === "流程中" ? badge("流程中","blue") : badge("待签约","amber")}${signMonthCell}${money(pf.sign_amount)}${mCols}${totalCol}${esc(pf.sales_person) || "—"}${esc(pf.owner) || "—"}`; + }; + + document.querySelector("#finance").innerHTML = `
+
+ ${[["已签项目","" + 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(`

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

${[["已签约","已签约"],["流程中","流程中"],["待签约","待签约"]].map(([k,v]) => ``).join("")}
${monthLabels.map(l => ``).join("")}${pfs.filter(x => x.status === state.finFilter).map(renderPfRow).join("")}
项目名称类型状态签约月份签约金额${l}
${state.finView !== 'cashflow' ? '确收/毛利' : '回款/费用'}
总计
${state.finView !== 'cashflow' ? '确收/毛利' : '回款/费用'}
商务负责人经营负责人
`, "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; + const pfIdInput = form.querySelector('[name="pf_id"]'); + if (!pfIdInput || !pfIdInput.value) { + initBudgetTable(null); + document.querySelector("#financeDeleteBtn").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.updateBudgetSummary = () => { + const revEl = document.querySelector("#budgetTotalRev"); + const grossEl = document.querySelector("#budgetTotalGross"); + const paymentEl = document.querySelector("#budgetTotalPayment"); + const costEl = document.querySelector("#budgetTotalCost"); + if (!revEl || !grossEl) return; + const revInputs = document.querySelectorAll('[name="budget_rev[]"]'); + const grossInputs = document.querySelectorAll('[name="budget_gross[]"]'); + const paymentInputs = document.querySelectorAll('[name="budget_payment[]"]'); + const costInputs = document.querySelectorAll('[name="budget_cost[]"]'); + let totalRev = 0, totalGross = 0, totalPayment = 0, totalCost = 0; + revInputs.forEach(el => { totalRev += parseFloat(el.value) || 0; }); + grossInputs.forEach(el => { totalGross += parseFloat(el.value) || 0; }); + paymentInputs.forEach(el => { totalPayment += parseFloat(el.value) || 0; }); + costInputs.forEach(el => { totalCost += parseFloat(el.value) || 0; }); + revEl.textContent = money(totalRev); + grossEl.textContent = money(totalGross); + if (paymentEl) paymentEl.textContent = money(totalPayment); + if (costEl) costEl.textContent = money(totalCost); +}; + +window.initBudgetTable = (budgetData) => { + const tbody = document.querySelector("#budgetTbody"); + if (!tbody) return; + tbody.innerHTML = ""; + const rows = budgetData || []; + rows.forEach(r => addBudgetRow(r.month || '', r.rev || '', r.gross || '', r.payment || '', r.cost || '')); + setTimeout(() => updateBudgetSummary(), 50); +}; + +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 = "form-ctrl form-ctrl-sm w-full"; + 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) { toast("修改失败:" + e.message, "error"); } + }); + 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("#financeDeleteBtn").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 || ""; + const signMonthValue = pf.sign_month || ""; + const signMonthEl = form.querySelector('[name="sign_month"]'); + if (signMonthEl && signMonthValue) { + signMonthEl.innerHTML = monthOptions(signMonthValue); + signMonthEl.value = signMonthValue; + } + form.querySelector('[name="status"]').value = pf.status || "待签约"; + form.querySelector('[name="sales_person"]').value = pf.sales_person || ""; + form.querySelector('[name="owner"]').value = pf.owner || ""; + let budgetData = []; + try { budgetData = JSON.parse(pf.budget_data || "[]"); } catch (e) { budgetData = []; } + initBudgetTable(budgetData.length ? budgetData : null); + setTimeout(() => updateBudgetSummary(), 100); + openFinanceModal(); +}; + +window.createFinance = async (event) => { + event.preventDefault(); + const form = event.currentTarget; + const data = Object.fromEntries(new FormData(form).entries()); + data.tenant = state.tenant; + // 必填校验 + if (!data.customer_name || !data.customer_name.trim()) { toast("项目名称必填", "error"); return; } + if (!data.sales_person || !data.sales_person.trim()) { toast("商务负责人必填", "error"); return; } + if (!data.owner || !data.owner.trim()) { toast("经营负责人必填", "error"); return; } + if (!data.sign_month) { toast("签约月份必填", "error"); return; } + data.sign_amount = parseFloat(data.sign_amount) || 0; + if (!(data.sign_amount > 0)) { toast("签约金额必须大于 0", "error"); return; } + 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 = []; + let totalRev = 0, totalGross = 0; + for (let i = 0; i < months.length; i++) { + const m = months[i].value.trim(); + if (!m) continue; + const rev = parseFloat(revs[i].value) || 0; + const gross = parseFloat(grosses[i].value) || 0; + const payment = parseFloat(payments[i].value) || 0; + const cost = parseFloat(costs[i].value) || 0; + budgetRows.push({ month: m, rev, gross, payment, cost }); + totalRev += rev; + totalGross += gross; + } + data.budget_data = JSON.stringify(budgetRows); + data.total_rev = totalRev; + data.total_gross = totalGross; + 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 { + 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 = ""; + document.querySelector("#financeModalTitle").textContent = "新增项目财务"; + closeFinanceModal(); + await load(); + } catch (error) { + toast("保存失败:" + error.message, "error"); + } +}; + +window.deleteFinanceItem = async () => { + const pfId = document.querySelector("#pf-id-input").value; + if (!pfId) return; + const pf = (state.data.projectFinances || []).find(x => x.id === parseInt(pfId)); + const name = pf ? (pf.customer_name || "此项目") : "此项目"; + if (!confirm(`确认删除「${name}」?此操作不可撤销。`)) return; + try { + await api(`/api/projectFinances/${pfId}`, { method: "DELETE" }); + closeFinanceModal(); + await load(); + toast("已删除", "success"); + } catch (error) { + toast("删除失败:" + error.message, "error"); + } +}; diff --git a/static/modules/home.js b/static/modules/home.js new file mode 100644 index 0000000..9932ddb --- /dev/null +++ b/static/modules/home.js @@ -0,0 +1,103 @@ +// home.js — 首页渲染 + 财务趋势图 + +function renderHome() { + const { summary, financeMonthly } = state.data; + const m = summary.metrics; + const rows1 = [ + ["年度累计签约", money(m.signed_annual || m.signed_amount)], + ["Q2 累计签约", money(m.signed_q2 || 0)], + ["本月新增签约", money(m.signed_month || 0)], + ]; + const rows2 = [ + ["年度累计确收", money(m.revenue_annual)], + ["Q2 累计确收", money(m.revenue_q2)], + ["本月新增确收", money(m.monthly_revenue)], + ]; + const rows3 = [ + ["年度累计毛利", money(m.gross_annual)], + ["Q2 累计毛利", money(m.gross_q2)], + ["本月新增毛利", money(m.monthly_net_profit)], + ]; + const tblCard = (title, rows) => card(`

${title}

${rows.map(([label, value]) => ``).join("")}
${label}${value}
`, "p-4"); + document.querySelector("#home").innerHTML = ` +
+
+ ${[ + ["经营管理", 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)}
+
+ ${card(`

月度签约趋势

2026
`, "p-4")} + ${card(`

月度确收与毛利

2026
`, "p-4")} + ${card(`

月度回款与费用

2026
`, "p-4")} +
+ ${card(`

近期动态

${summary.recent.map((r) => `
${r.content}
${r.followed_at}
`).join("")}
`, "p-5")} +
+ `; + renderCharts(financeMonthly); +} + +function chartOptions(yCallback) { + return { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } }, + scales: { + x: { ticks: { font: { size: 10 } }, grid: { display: false } }, + y: { ticks: { font: { size: 11 }, callback: yCallback } }, + }, + }; +} + +const moneyTick = (v) => v >= 10000 ? (v / 10000).toFixed(0) + "万" : v; +const monthLabels = (data) => data.map((x) => parseInt(x.month.split("-")[1]) + "月"); + +function renderCharts(data) { + const labels = monthLabels(data); + const baseOpts = chartOptions(moneyTick); + + // 图1:月度签约 + const c1 = document.querySelector("#chartSign"); + if (c1 && window.Chart) { + if (state.chart) state.chart.destroy(); + state.chart = new Chart(c1, { + type: "line", + data: { labels, datasets: [ + { label: "签约金额", data: data.map((x) => x.sign || 0), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 }, + ]}, + options: baseOpts, + }); + } + + // 图2:月度确收与毛利 + const c2 = document.querySelector("#chartRev"); + if (c2 && window.Chart) { + if (state.chart2) state.chart2.destroy(); + state.chart2 = new Chart(c2, { + type: "line", + data: { labels, datasets: [ + { label: "确收", data: data.map((x) => x.revenue || 0), borderColor: "#2563eb", backgroundColor: "rgba(37,99,235,0.06)", fill: true, tension: 0.3 }, + { label: "毛利", data: data.map((x) => x.net_profit || 0), borderColor: "#059669", backgroundColor: "rgba(5,150,105,0.06)", fill: true, tension: 0.3 }, + ]}, + options: baseOpts, + }); + } + + // 图3:月度回款与费用 + const c3 = document.querySelector("#chartCash"); + if (c3 && window.Chart) { + if (state.chart3) state.chart3.destroy(); + state.chart3 = new Chart(c3, { + type: "bar", + data: { labels, datasets: [ + { label: "回款", data: data.map((x) => x.payment || 0), backgroundColor: "#d97706", borderRadius: 4 }, + { label: "费用", data: data.map((x) => x.cost || 0), backgroundColor: "#ef4444", borderRadius: 4 }, + ]}, + options: baseOpts, + }); + } +} diff --git a/static/modules/products.js b/static/modules/products.js new file mode 100644 index 0000000..f2c5526 --- /dev/null +++ b/static/modules/products.js @@ -0,0 +1,204 @@ +// products.js — 产品迭代模块 + +function formHtml(fields, button) { + return `
+ ${fields.map((f) => ``).join("")} + +
`; +} + +async function createResource(event, resource) { + event.preventDefault(); + const form = event.currentTarget; + const data = Object.fromEntries(new FormData(form).entries()); + data.tenant = state.tenant; + try { + 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) { + toast("创建失败:" + error.message, "error"); + } +} + +window.createSales = (event) => createResource(event, "sales"); +window.createProposal = (event) => createResource(event, "proposals"); +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) { + toast("更新失败:" + error.message, "error"); + } +}; + +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 = "form-ctrl form-ctrl-sm w-full"; + 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) { toast("修改失败:" + e.message, "error"); } + }); + input.addEventListener("blur", () => { + td.innerHTML = `${currentValue || '—'}`; + }); + td.innerHTML = ""; + td.appendChild(input); + input.focus(); +}; + +window.addNewFeature = () => { + const list = document.querySelector("#newFeatureList"); + if (!list) return; + const idx = list.children.length; + const div = document.createElement("div"); + div.className = "feature-item"; + div.innerHTML = `${idx+1}.`; + list.appendChild(div); + if (window.lucide) window.lucide.createIcons(); +}; + +window.removeNewFeature = (btn) => { + const div = btn.closest(".feature-item"); + if (!div) return; + div.remove(); + const list = document.querySelector("#newFeatureList"); + if (list) list.querySelectorAll(".feature-num").forEach((el, i) => { el.textContent = (i+1) + "."; }); +}; + +window.submitProductDrawer = async (event) => { + event.preventDefault(); + const form = event.currentTarget; + const data = Object.fromEntries(new FormData(form).entries()); + const featureInputs = form.querySelectorAll("#newFeatureList input"); + data.feature_list = [...featureInputs].map(el => el.value.trim()).filter(Boolean).join("\n"); + 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) { + toast("创建失败:" + error.message, "error"); + } +}; + +window.addFeature = (id) => { + const list = document.querySelector(`#featureList_${id}`); + if (!list) return; + const idx = list.children.length; + const div = document.createElement("div"); + div.className = "feature-item"; + div.innerHTML = `${idx+1}.`; + list.appendChild(div); + if (window.lucide) window.lucide.createIcons(); + saveFeatureList(id); +}; + +window.removeFeature = (id, idx) => { + const list = document.querySelector(`#featureList_${id}`); + if (!list) return; + const items = list.querySelectorAll(".feature-item"); + if (items[idx]) items[idx].remove(); + list.querySelectorAll(".feature-num").forEach((el, i) => { el.textContent = (i+1) + "."; }); + saveFeatureList(id); +}; + +window.saveFeatureList = (id) => { + const list = document.querySelector(`#featureList_${id}`); + if (!list) return; + const values = [...list.querySelectorAll("input")].map(el => el.value.trim()).filter(Boolean); + const data = values.join("\n"); + api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { feature_list: data } }) }); + const product = (state.data.products || []).find(x => x.id === id); + if (product) product.feature_list = data; +}; + +function renderProducts() { + const items = state.data.products || []; + document.querySelector("#products").innerHTML = ` +
+
+ +
+
+ ${items.map((p) => ` +
+
+

${esc(p.product_name)}

+ ${esc(p.status) || '规划中'} +
+
+ ${esc(p.version)} + · + ${esc(p.launch_date) || '—'} +
+
+

${esc(p.version_goal) || '—'}

+
${esc(p.feature_list) || '—'}
+
+
+ `).join("")} +
+
+ + `; +} diff --git a/static/modules/projects.js b/static/modules/projects.js new file mode 100644 index 0000000..543b58a --- /dev/null +++ b/static/modules/projects.js @@ -0,0 +1,519 @@ +// projects.js — 重点工作与台账(项目管理 + 任务管理) + +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; + const avatar = document.querySelector("#userAvatar"); + avatar.textContent = user.display_name.charAt(0); + avatar.title = user.display_name; + const nameEl = document.querySelector("#userDisplayName"); + if (nameEl) { nameEl.textContent = user.display_name; nameEl.title = user.display_name; } + avatar.addEventListener("click", (e) => { + e.stopPropagation(); + toggleUserMenu(user); + }); + const allowedTenants = data.tenants || []; + document.querySelectorAll(".workspace-nav-item").forEach(el => { + el.style.display = allowedTenants.includes(el.dataset.tenant) ? "" : "none"; + }); + }); +} + +window.toggleUserMenu = (user) => { + let menu = document.getElementById("userMenu"); + if (menu) { menu.remove(); return; } + const avatar = document.querySelector("#userAvatar"); + const rect = avatar.getBoundingClientRect(); + menu = document.createElement("div"); + menu.id = "userMenu"; + menu.className = "fixed bg-white rounded-lg shadow-xl border border-slate-200 py-1 min-w-[160px] z-[9999]"; + menu.style.left = Math.min(rect.left - 8, window.innerWidth - 180) + "px"; + menu.style.top = rect.bottom + 6 + "px"; + menu.innerHTML = ` +
+

${esc(user.display_name)}

+

${esc(user.username || "")}

+
+ ${user.role === 'admin' ? `` : ''} + `; + document.body.appendChild(menu); + if (window.lucide) lucide.createIcons(); + setTimeout(() => { + document.addEventListener("click", function closeMenu() { + menu.remove(); + document.removeEventListener("click", closeMenu); + }, { once: true }); + }, 10); +}; + +window.closeUserMenu = () => { + const m = document.getElementById("userMenu"); + if (m) m.remove(); +}; + +window.selectProject = (id) => { + state.selectedProject = id; + document.querySelectorAll(".project-tree-node").forEach((el) => el.classList.toggle("active", parseInt(el.dataset.id) === id)); + renderProjectTasks(id); +}; + +window.togglePhase = (phaseId) => { + const wrap = document.querySelector(`#${phaseId}`); + if (!wrap) return; + wrap.classList.toggle("collapsed"); + const toggle = document.querySelector(`#${phaseId}-toggle`); + if (toggle) toggle.style.transform = wrap.classList.contains("collapsed") ? "rotate(-90deg)" : ""; +}; + +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 menu = document.querySelector("#projectContextMenu"); + if (menu) { + const id = parseInt(menu.dataset.projectId); + if (id) openDrawer("operations", id); + } +}; + +window.renameProject = async () => { + const menu = document.querySelector("#projectContextMenu"); + if (!menu) return; + const id = parseInt(menu.dataset.projectId); + if (!id) return; + const project = (state.data.operations || []).find(x => x.id === id); + if (!project) return; + const newName = prompt("请输入新的项目名称:", project.project_name); + if (!newName || newName.trim() === project.project_name) return; + try { + await api(`/api/operations/${id}`, { method: "PUT", body: JSON.stringify({ data: { project_name: newName.trim() } }) }); + project.project_name = newName.trim(); + renderProjects(); + toast("已重命名", "success"); + } catch (error) { + toast("重命名失败:" + error.message, "error"); + } +}; + +window.duplicateProject = async () => { + const menu = document.querySelector("#projectContextMenu"); + if (!menu) return; + const id = parseInt(menu.dataset.projectId); + if (!id) return; + const project = (state.data.operations || []).find(x => x.id === id); + if (!project) return; + const newName = prompt("请输入副本项目名称:", project.project_name + " - 副本"); + if (!newName) return; + try { + const result = await api("/api/operations", { + method: "POST", + body: JSON.stringify({ data: { + project_name: newName.trim(), + project_version: project.project_version || "v1.0", + project_type: project.project_type || "opportunity", + project_status: project.project_status || "", + current_stage: project.current_stage || "", + owner: project.owner || "慰心", + target_customer: project.target_customer || "", + customer_need: project.customer_need || "", + expected_contract_amount: project.expected_contract_amount || 0, + expected_sign_date: project.expected_sign_date || "", + sign_probability: project.sign_probability || 0, + next_action: project.next_action || "", + sop_stage: project.sop_stage || "", + execution_progress: project.execution_progress || 0, + current_deliverable: project.current_deliverable || "", + risks: project.risks || "", + notes: project.notes || "", + tenant: state.tenant, + }}), + }); + // 复制任务 + const tasks = (state.data.tasks || []).filter(t => t.project_id === id); + for (const t of tasks) { + await api("/api/tasks", { + method: "POST", + body: JSON.stringify({ data: { + project_id: result.id, + phase: t.phase || "", + milestone: t.milestone || "", + task: t.task || "", + owner: t.owner || "", + due_date: t.due_date || "", + blockers: t.blockers || "", + notes: t.notes || "", + status: "未开始", + priority: t.priority || "P2", + sort_order: t.sort_order || 0, + tenant: state.tenant, + }}), + }); + } + toast("已创建副本", "success"); + await load(); + } catch (error) { + toast("创建副本失败:" + error.message, "error"); + } +}; + +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; + if (!state.selectedProject && items.length > 0) { + state.selectedProject = items[0].id; + } + const tasks = state.data.tasks || []; + const taskStats = { + total: tasks.length, + ongoing: tasks.filter(t => t.status === '进行中').length, + done: tasks.filter(t => t.status === '已结束').length, + pending: tasks.filter(t => t.status === '未开始').length, + }; + document.querySelector("#projects").innerHTML = /*html*/` +
+ ${[ + ["项目总数", items.length], + ["任务总数", taskStats.total], + ["进行中", taskStats.ongoing], + ["已结束", taskStats.done], + ["未开始", taskStats.pending], + ].map(([label, value]) => ` +

${label}

${value}

+ `).join("")} +
+
+
+ + +
+ +
+
+
+
+
+ 项目 + +
+
+ ${items.map((x) => ` +
+ + ${esc(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); + + 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 filtered = 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) => `
+ + ${esc(t.status) || '未开始'} + ${esc(t.priority) || 'P2'} +
+ ${esc(t.task)} + ${state.taskView === 'detail' && t.notes ? '' + esc(t.notes) + '' : ""} + ${state.taskView === 'detail' && t.blockers ? '\u26a0 ' + esc(t.blockers) + '' : ""} +
+ ${esc(t.owner) || ''} + ${esc(t.due_date) || ''} +
`).join("")} +
+
+
`; + }).join("")} + ${filtered.length === 0 ? '
暂无任务,点击上方按钮创建
' : ''} + `; +} + +function renderProjectTasks(projectId) { + const project = state.data.operations.find((x) => x.id === projectId); + if (!project) { state.selectedProject = null; renderProjects(); return; } + const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId); + 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); + if (window.lucide) window.lucide.createIcons(); +} +window.openTaskFormForSelected = () => { + openTaskForm(state.selectedProject, null); +}; + +window.openTaskForm = (projectId, taskId) => { + if (!projectId) return; + // 确保 drawer 存在 + let drawer = document.querySelector(`#task-drawer-${projectId}`); + if (!drawer) { + drawer = document.createElement("div"); + drawer.id = `task-drawer-${projectId}`; + drawer.className = "task-drawer"; + document.body.appendChild(drawer); + } + const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId); + const defaultPhases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"]; + const customPhases = [...new Set(tasks.map(t => t.phase).filter(Boolean))]; + const phases = [...new Set([...defaultPhases, ...customPhases])]; + const task = taskId ? (state.data.tasks || []).find((t) => t.id === taskId) : null; + drawer.innerHTML = `
${task ? "编辑任务" : "新增任务"}
${task ? `` : ""}
+
+ +
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
`; + drawer.classList.add("open"); + if (window.lucide) window.lucide.createIcons(); +}; + +window.closeTaskDrawer = (projectId) => { + const drawer = document.querySelector(`#task-drawer-${projectId}`); + if (drawer) drawer.classList.remove("open"); + refreshTaskList(projectId); +}; + +window.refreshTaskList = (projectId) => { + const body = document.querySelector(".task-feed-body"); + if (body && state.selectedProject === projectId) { + body.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 + "」"); + closeTaskDrawer(projectId); + } else { + const result = await api("/api/tasks", { method: "POST", body: JSON.stringify({ data }) }); + if (result.id && data.task) logActivity("task", result.id, "创建了任务「" + data.task + "」"); + closeTaskDrawer(projectId); + await load(); + } + } catch (error) { + toast("保存失败:" + error.message, "error"); + } +}; + +window.cycleTaskStatus = async (taskId, projectId) => { + const tasks = state.data.tasks || []; + const task = tasks.find((t) => t.id === taskId); + if (!task) return; + 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 } }) }); + 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) { + toast("更新失败:" + error.message, "error"); + } +}; + +window.cycleTaskPriority = async (taskId, projectId) => { + const tasks = state.data.tasks || []; + const task = tasks.find((t) => t.id === taskId); + if (!task) return; + const priorities = ["P0", "P1", "P2", "P3"]; + const current = priorities.indexOf(task.priority) >= 0 ? task.priority : "P2"; + const newPriority = priorities[(priorities.indexOf(current) + 1) % priorities.length]; + try { + await api(`/api/tasks/${taskId}`, { method: "PUT", body: JSON.stringify({ data: { priority: newPriority } }) }); + task.priority = newPriority; + const row = document.querySelector(`.task-item[data-id="${taskId}"]`); + if (row) { + row.classList.remove("task-p0", "task-p1"); + if (newPriority === "P0") row.classList.add("task-p0"); + else if (newPriority === "P1") row.classList.add("task-p1"); + const badge = row.querySelector(".task-priority-badge"); + if (badge) { + badge.textContent = newPriority; + badge.className = "task-priority-badge priority-" + newPriority.toLowerCase(); + } + } + } catch (error) { + toast("更新失败:" + error.message, "error"); + } +}; + +window.deleteTask = async (projectId) => { + const taskId = document.querySelector(`#task-id-${projectId}`).value; + 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); + state.data.tasks = (state.data.tasks || []).filter(t => t.id !== parseInt(taskId)); + const row = document.querySelector(`.task-item[data-id="${taskId}"]`); + if (row) row.remove(); + } catch (error) { + toast("删除失败:" + error.message, "error"); + } +}; + +// 拖拽排序 +let dragTaskId = null; +window.handleTaskDragStart = (event, taskId) => { + dragTaskId = taskId; + event.currentTarget.classList.add("dragging"); + event.dataTransfer.effectAllowed = "move"; +}; + +window.handleTaskDrop = async (event, projectId, phase) => { + event.preventDefault(); + event.currentTarget.classList.remove("drag-over"); + const target = event.currentTarget; + if (!dragTaskId) return; + const dragged = document.querySelector(`.task-item[data-id="${dragTaskId}"]`); + if (!dragged) return; + const afterElement = getDragAfterElement(target, event.clientY); + if (afterElement) { + target.insertBefore(dragged, afterElement); + } else { + target.appendChild(dragged); + } + dragged.classList.remove("dragging"); + 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 }) }); + } catch (e) { /* non-critical */ } + dragTaskId = null; +}; + +function getDragAfterElement(container, y) { + 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; + if (offset < 0 && offset > closest.offset) { + return { offset: offset, element: child }; + } else { + return closest; + } + }, { offset: Number.NEGATIVE_INFINITY }).element; +} diff --git a/static/modules/proposals.js b/static/modules/proposals.js new file mode 100644 index 0000000..3cc03f6 --- /dev/null +++ b/static/modules/proposals.js @@ -0,0 +1,335 @@ +// proposals.js — 业务方案 + 文件管理 + +// 标准资料库固定 7 项 +const STANDARD_PROPOSALS = [ + "业务方案-医生版", + "业务方案-药企版", + "服务清单与报价单", + "患者服务清单", + "医生项目清单与劳务报价", + "项目执行 SOP", + "财务结算流程", +]; + +// 确保标准资料库已初始化(首次进入时创建) +async function ensureStandardProposals() { + const existing = (state.data.proposals || []).filter(p => p.proposal_type === "标准资料"); + const missing = STANDARD_PROPOSALS.filter(name => !existing.find(p => p.customer_or_project_name === name)); + if (missing.length === 0) return; + for (const name of missing) { + try { + await api("/api/proposals", { + method: "POST", + body: JSON.stringify({ data: { + customer_or_project_name: name, + proposal_type: "标准资料", + notes: "", + version: "v1.0", + status: "已归档", + created_date: new Date().toISOString().slice(0, 10), + tenant: state.tenant, + }}), + }); + } catch (e) { /* ignore */ } + } + await load(); +} + +window.switchProposalTab = (tab) => { + state.proposalTab = tab; + renderProposals(); +}; + +function renderProposals() { + const items = state.data.proposals || []; + const standardItems = items.filter(p => p.proposal_type === "标准资料"); + const otherItems = items.filter(p => p.proposal_type !== "标准资料"); + const isStandard = state.proposalTab === "standard"; + + document.querySelector("#proposals").innerHTML = `
+
+
+ + +
+ ${!isStandard ? `` : ''} +
+ ${isStandard ? `
这是每一条 OPC 线,必须要梳理清楚的 7 份资料,项目不可以删除,只可以更新附件,请大家将最新的材料上传
` : `
在这里新建,并且上传您希望与团队其他成员共享的资料
`} + ${isStandard ? renderStandardTable(standardItems) : renderOtherTable(otherItems)} +
+ `; + if (window.lucide) window.lucide.createIcons(); +} + +// 标准资料库:按固定顺序排序,点击行打开附件抽屉(不含删除按钮) +function renderStandardTable(items) { + const sorted = STANDARD_PROPOSALS.map(name => items.find(p => p.customer_or_project_name === name)).filter(Boolean); + const rows = sorted.map((p) => { + const fileCount = (p.files || []).length; + return ` + ${esc(p.customer_or_project_name)} + ${fileCount} 个文件 + ${(p.created_at || "").slice(0,10) || "—"} + `; + }).join(""); + return `
+
+ + + + + + + ${rows} +
资料名称附件创建日期
+
+
`; +} + +// 其他资料:原有表格 + 行点击打开抽屉 +function renderOtherTable(items) { + const rows = items.map((p) => [ + `${esc(p.customer_or_project_name)}`, + p.proposal_type || "业务方案", + text(p.notes || ""), + (p.created_at || "").slice(0, 10) || "\u2014", + ]); + return renderTable(["方案名称", "方案类型", "方案说明", "日期"], rows, items.map((p) => ({ resource: "proposals", id: p.id }))); +} + +// 标准资料专用抽屉(附件管理 + 评论,不能编辑字段、不能删除项目) +window.openStandardProposalDrawer = (id) => { + const item = (state.data.proposals || []).find(p => p.id === id); + if (!item) return; + const drawer = document.querySelector("#drawer"); + const title = esc(item.customer_or_project_name); + const followupTarget = "proposal"; + drawer.innerHTML = `

标准资料

${title}

+
+

附件管理

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

活动 / 跟进

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

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

` : ""}
`).join("")}
+
+
+ + + + + + + + + + + +
+
+
+ 支持富文本编辑 + +
+
+
` : ""} +
+
`; + drawer.classList.add("open"); + if (window.lucide) window.lucide.createIcons(); + renderUploadTasks(); + // 渲染富文本评论内容 + drawer.querySelectorAll(".rich-content").forEach((el) => { + const html = el.dataset.html; + if (html) el.innerHTML = decodeURIComponent(html); + }); + // 初始化 Squire 编辑器 + const squireDiv = drawer.querySelector(".squire-editor"); + if (squireDiv && window.Squire) { + const sid = squireDiv.id; + if (window.squireInstances[sid]) window.squireInstances[sid].destroy(); + const sq = new Squire(squireDiv, { blockTag: "P" }); + window.squireInstances[sid] = sq; + squireDiv.addEventListener("focus", () => squireDiv.classList.add("focused")); + squireDiv.addEventListener("blur", () => { + if (!squireDiv.textContent.trim()) squireDiv.classList.remove("focused"); + }); + } +}; + +// 标准资料评论提交(提交后重新打开标准资料抽屉) +window.submitStandardComment = async (event, targetId) => { + event.preventDefault(); + const form = event.currentTarget; + const editorDiv = form.querySelector(".squire-editor"); + const sq = window.squireInstances[editorDiv.id]; + const content = sq ? sq.getHTML().trim() : ""; + if (!content || content === "

" || content === "


") return; + const button = form.querySelector(".comment-submit"); + button.disabled = true; + button.textContent = "发送中…"; + await api(`/api/followups/proposal/${targetId}`, { method: "POST", body: JSON.stringify({ data: { content } }) }); + await load(); + openStandardProposalDrawer(targetId); +}; + +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) { + toast("保存失败:" + error.message, "error"); + } +}; + +// 文件管理 +function fileGroup(module, ownerId, version, category, files) { + return `
+

${category}

+
${files.length ? files.map(fileItem).join("") : `

暂无文件

`}
+
`; +} + +function fileItem(file) { + return `

${esc(file.file_name)}

`; +} + +window.deleteFile = async (fileId) => { + if (!confirm("确认删除此文件?")) return; + await api(`/api/files/${fileId}`, { method: "DELETE" }); + // 优先在当前打开的抽屉中查找并刷新 + const drawer = document.querySelector("#drawer.open"); + if (drawer) { + const uploadList = drawer.querySelector("#uploadTaskList"); + // 通过 file.id 反查所属 item + 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); + // 判断是标准资料还是普通抽屉 + if (item.proposal_type === "标准资料") { + openStandardProposalDrawer(item.id); + } else { + openDrawer(listKey, item.id); + } + return; + } + } + } + } +}; + +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); + + 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 }); + // 刷新当前抽屉 + if (item.proposal_type === "标准资料") { + openStandardProposalDrawer(item.id); + } else if (document.querySelector("#drawer.open")) { + openDrawer(listKey, item.id); + } + } + } + setTimeout(() => { + state.uploadTasks = state.uploadTasks.filter(t => t.id !== taskId); + renderUploadTasks(); + }, 1500); + } + }); + xhr.addEventListener("error", () => { + toast("上传失败:" + file.name, "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 => ` +
+ ${esc(t.name)} +
+ ${t.progress}% + +
+ `).join(""); + if (window.lucide) window.lucide.createIcons(); +}; diff --git a/static/modules/utils.js b/static/modules/utils.js new file mode 100644 index 0000000..74b495e --- /dev/null +++ b/static/modules/utils.js @@ -0,0 +1,188 @@ +// utils.js — 全局工具函数与共享状态 + +const state = { + active: "home", + data: null, + tenant: "科普·无界", + opFilter: "all", + finFilter: "已签约", + selectedProject: null, + taskQuery: "", + taskView: localStorage.getItem("opc-task-view") || "detail", + finView: localStorage.getItem("opc-fin-view") || "rev", + proposalTab: "standard", + chart: null, + chart2: null, + chart3: null, + productPlatform: "all", + uploadTasks: [], +}; + +const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")} 元`; +const text = (value) => value === undefined || value === null || value === "" ? "—" : esc(value); + +function escapeHtml(str) { return String(str || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } +const html = escapeHtml; +const esc = escapeHtml; + +// Toast 通知 +function toast(message, type = "info", duration = 3000) { + let container = document.querySelector(".toast-container"); + if (!container) { + container = document.createElement("div"); + container.className = "toast-container"; + document.body.appendChild(container); + } + const el = document.createElement("div"); + el.className = `toast toast-${type}`; + const icon = type === "success" ? "check-circle" : type === "error" ? "alert-circle" : "info"; + el.innerHTML = `${esc(message)}`; + container.appendChild(el); + if (window.lucide) window.lucide.createIcons(); + setTimeout(() => { + el.classList.add("fade-out"); + setTimeout(() => el.remove(), 250); + }, duration); +} +window.toast = toast; + +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, { + headers: options.body instanceof FormData ? undefined : { "Content-Type": "application/json" }, + ...options, + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "请求失败"); + 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"; + if (["P0", "有风险", "已丢单", "已延期"].includes(val)) cls = "badge-red"; + if (["P1", "方案中", "方案已提交", "商务谈判", "待客户确认"].includes(val)) cls = "badge-amber"; + if (["已签约", "已上线", "已完成", "已归档"].includes(val)) cls = "badge-green"; + if (["execution", "已签约执行项目"].includes(val)) cls = "badge-blue"; + return `${val === "execution" ? "已签约执行项目" : val === "opportunity" ? "业务机会项目" : val}`; +} + +function card(content, cls = "") { + return `
${content}
`; +} + +function renderTable(headers, rows, rowClicks) { + const trAttrs = (rowClicks || []).map((rc) => rc ? `onclick="openDrawer('${rc.resource}', ${rc.id})" class="clickable-row"` : ""); + return card(` +
+ + ${headers.map((h) => ``).join("")} + ${rows.map((row, i) => `${row.map((c) => ``).join("")}`).join("")} +
${h}
${c}
+
+ `); +} + +async function load() { + state.data = await api(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`); + // 首次加载时确保标准资料库 7 项已初始化 + if (typeof ensureStandardProposals === "function") { + const existing = (state.data.proposals || []).filter(p => p.proposal_type === "标准资料"); + const missingCount = 7 - existing.length; + if (missingCount > 0) { + await ensureStandardProposals(); + return; // ensureStandardProposals 内部会再次 render + } + } + render(); +} + +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(); +} + +function render() { + if (!state.data) return; + renderHome(); + renderProjects(); + renderProposals(); + renderProducts(); + renderFinance(); + if (window.lucide) window.lucide.createIcons(); +} + +function renderActive() { + if (!state.data) return; + const tab = state.active; + if (tab === "home") renderHome(); + else if (tab === "projects") renderProjects(); + else if (tab === "proposals") renderProposals(); + else if (tab === "products") renderProducts(); + else if (tab === "finance") renderFinance(); + if (window.lucide) window.lucide.createIcons(); +} + +window.setTaskView = (view) => { + state.taskView = view; + localStorage.setItem("opc-task-view", view); + // 更新按钮选中状态 + const toggle = document.querySelector("#taskViewToggle"); + if (toggle) { + toggle.querySelectorAll("button").forEach((btn, i) => { + const isCompact = i === 0; + btn.className = `btn btn-sm ${(isCompact ? view === 'compact' : view !== 'compact') ? 'btn-primary' : 'btn-ghost'} p-1.5`; + }); + } + if (state.selectedProject) { + const body = document.querySelector(".task-feed-body"); + if (body) body.innerHTML = renderTaskListHTML(state.selectedProject); + if (window.lucide) window.lucide.createIcons(); + } +}; + +window.switchTab = switchTab; + +window.setFinView = (view) => { + state.finView = view; + localStorage.setItem("opc-fin-view", view); + renderFinance(); +}; +window.switchTenant = (tenant) => { + state.tenant = tenant; + state.selectedProject = null; + localStorage.setItem("opc-active-tenant", tenant); + document.querySelector("#workspaceTitle").textContent = tenant.replace("·无界", "") + " OPC 工作台"; + document.querySelectorAll(".workspace-nav-item").forEach((el) => el.classList.toggle("active", el.dataset.tenant === tenant)); + load(); +}; +window.doLogout = async () => { + await api("/api/auth/logout", { method: "POST" }); + location.href = "/login"; +}; diff --git a/static/styles.css b/static/styles.css index f1e0ff4..f81bb49 100644 --- a/static/styles.css +++ b/static/styles.css @@ -246,6 +246,7 @@ body { flex-direction: column; overflow-y: auto; min-width: 0; + border-left: 1px solid #edf2f7; } .task-feed-hd { @@ -264,7 +265,9 @@ body { .task-section { border-bottom: 1px solid #edf2f7; + border-top: 1px solid #edf2f7; } +.task-section:first-child { border-top: none; } .task-section-hd { display: flex; @@ -318,10 +321,13 @@ body { display: flex; align-items: center; gap: 10px; + height: 60px; padding: 9px 20px; cursor: pointer; transition: background 0.1s; + border-bottom: 1px solid #f1f5f9; } +.task-item:last-child { border-bottom: none; } .task-item:hover { background: #f9fafb; @@ -349,12 +355,16 @@ body { .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; } +.status-badge { + flex-shrink: 0; display: inline-flex; align-items: center; + font-size: 12px; font-weight: 500; padding: 2px 10px; + border-radius: 10px; cursor: pointer; white-space: nowrap; + transition: background 0.15s; +} /* 优先级底色 */ .task-p0 { background: #fef2f2; } @@ -401,12 +411,14 @@ body { flex-direction: column; gap: 1px; } +.task-item.task-detail .task-content { flex-direction: row; align-items: center; gap: 8px; } .task-feed .task-title { font-weight: 400; color: #1f2937; font-size: 12px; } +.task-item.task-detail .task-title { font-weight: 500; white-space: nowrap; flex-shrink: 0; } .task-desc { font-size: 12px; @@ -431,6 +443,16 @@ body { color: #ef4444; margin-top: 2px; } +.task-item.task-detail .task-blocker { + display: inline; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 240px; + margin-top: 0; + flex-shrink: 1; + min-width: 0; +} .task-empty { display: flex; @@ -590,17 +612,69 @@ body { .btn-ghost { background: white; border: 1px solid #e2e8f0; color: #334155; } .btn-ghost:hover { background: #f8fafc; } +/* ===== 表单控件统一标准 ===== */ input, select, textarea { - border: 1px solid #cbd5e1; + border: 1px solid #e2e8f0; border-radius: 6px; - min-height: 38px; - padding: 8px 10px; + padding: 8px 12px; + font-size: 13px; + color: #1e293b; + background: #fff; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + height: 38px; + box-sizing: border-box; } +select { + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 32px; +} +input:focus, +select:focus, +textarea:focus { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08); +} +input::placeholder, +textarea::placeholder { color: #cbd5e1; } +input:disabled, +select:disabled, +textarea:disabled { background: #f8fafc; cursor: not-allowed; } -textarea { - min-height: 96px; +textarea { min-height: 80px; height: auto; resize: vertical; } + +/* 标准控件类 */ +.form-ctrl { + display: block; + height: 38px; + width: 100%; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 8px 12px; + font-size: 13px; + color: #1e293b; + background: #fff; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} +.form-ctrl:focus { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08); +} +.form-ctrl::placeholder { color: #cbd5e1; } +.form-ctrl:disabled { background: #f8fafc; cursor: not-allowed; } + +/* 紧凑变体 */ +.form-ctrl-sm { + padding: 5px 10px; + font-size: 12px; + height: 32px; } table { @@ -702,35 +776,6 @@ td { min-width: 0; } -.drawer-value { - background: transparent; - border: 1px solid transparent; - border-radius: 5px; - color: #0f172a; - font-size: 13px; - font-weight: 500; - min-height: 30px; - padding: 4px 8px; - width: 100%; -} - -.drawer-value:hover { - background: #f8fafc; - border-color: #e2e8f0; -} - -.drawer-value:focus { - background: white; - border-color: #60a5fa; - outline: none; -} - -.drawer-textarea { - line-height: 1.45; - min-height: 54px; - resize: vertical; -} - .activity-item { background: #f8fafc; border: 1px solid #e2e8f0; @@ -758,23 +803,6 @@ td { width: 13px; } -.inline-form input, -.inline-form select { - height: 40px; - border: 1px solid #cbd5e1; - border-radius: 6px; - padding: 8px 10px; - font-size: 14px; - background: white; - min-width: 120px; -} - -.inline-form input:focus, -.inline-form select:focus { - border-color: #3b82f6; - outline: none; -} - .clickable-row { cursor: pointer; transition: background 0.15s; @@ -1063,13 +1091,9 @@ td { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 14px; margin-bottom: 16px; } +.task-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 4px; } .task-field { display: flex; flex-direction: column; gap: 4px; } -.task-field span { color: #64748b; font-size: 12px; } -.task-field input, .task-field select, .task-field textarea { - background: #fff; border: 1px solid #e2e8f0; border-radius: 6px; - color: #1e293b; font-size: 13px; padding: 6px 10px; outline: none; -} -.task-field input:focus, .task-field select:focus, .task-field textarea:focus { border-color: #2563eb; } +.task-field span { color: #64748b; font-size: 12px; font-weight: 500; } .col-span-2 { grid-column: span 2; } .task-group-add { display: block; width: 100%; padding: 10px; text-align: center; @@ -1077,3 +1101,65 @@ td { border-top: 1px solid #24272d; cursor: pointer; } .task-group-add:hover { color: #e4e5e7; background: #24272d; } + +/* Feature list(产品版本核心功能编号列表) */ +.feature-item { + display: flex; align-items: center; gap: 8px; margin-bottom: 6px; +} +.feature-num { + color: #94a3b8; font-size: 13px; font-weight: 500; min-width: 22px; flex-shrink: 0; +} +.feature-item .form-ctrl { flex: 1; padding: 5px 10px; } +.feature-del { + flex-shrink: 0; display: flex; align-items: center; justify-content: center; + width: 22px; height: 22px; border-radius: 4px; border: none; background: none; + color: #94a3b8; cursor: pointer; transition: color 0.15s; +} +.feature-del:hover { color: #ef4444; background: #fef2f2; } + +/* Toast 通知 */ +.toast-container { + position: fixed; top: 16px; right: 16px; z-index: 9999; + display: flex; flex-direction: column; gap: 8px; pointer-events: none; +} +.toast { + pointer-events: auto; + display: flex; align-items: center; gap: 10px; + padding: 10px 16px; border-radius: 8px; + font-size: 13px; font-weight: 500; + box-shadow: 0 4px 12px rgba(0,0,0,0.12); + animation: toastIn 0.2s ease-out; max-width: 380px; +} +.toast.toast-success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; } +.toast.toast-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; } +.toast.toast-info { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; } +.toast.fade-out { animation: toastOut 0.25s ease-in forwards; } +@keyframes toastIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } } +@keyframes toastOut { to { opacity: 0; transform: translateX(20px); } } + +/* 财务弹窗优化 */ +.fin-field-group { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 18px; +} +.fin-section-label { + font-size: 13px; + font-weight: 600; + color: #334155; + margin-bottom: 14px; + padding-bottom: 8px; + border-bottom: 1px solid #e2e8f0; +} +.fin-label { + display: block; + font-size: 12px; + font-weight: 500; + color: #64748b; + margin-bottom: 4px; +} + +/* 项目树拖拽 */ +.project-tree-node.dragging { opacity: 0.4; } +.project-tree-node.drag-over { border-top: 2px solid #2563eb; } diff --git a/templates/index.html b/templates/index.html index cc5b35a..27d608f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -28,8 +28,11 @@
-
- - + +
@@ -121,6 +127,14 @@
+ + + + + + + + diff --git a/templates/login.html b/templates/login.html index 2e1d600..5ea7706 100644 --- a/templates/login.html +++ b/templates/login.html @@ -4,42 +4,99 @@ OPC 工作台 · 登录 - + + + - -
-
-

OPC 工作台

-

请输入账号密码登录

-
-
- - - - -
-

默认管理员:qiukai / yxcowork2026

+ +
+ - + } +