Compare commits
6 Commits
ac6eacea82
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c70293b447 | ||
|
|
ff7eb19d5d | ||
|
|
adeff08827 | ||
|
|
493150cb27 | ||
|
|
8bd40de41d | ||
|
|
caebf90438 |
76
backend/db.py
Normal file
76
backend/db.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# db.py — 基础层:配置常量 + 数据库连接 + SQL 工具 + logger
|
||||||
|
# 被 helpers.py, routes.py, migrations/*, flask_app.py 共同依赖
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import mysql.connector
|
||||||
|
|
||||||
|
# 确保 backend 目录在 sys.path 中(兼容 gunicorn --preload 模式)
|
||||||
|
_backend_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
if _backend_dir not in sys.path:
|
||||||
|
sys.path.insert(0, _backend_dir)
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
# ---------- 环境变量 ----------
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 数据库连接 ----------
|
||||||
|
def db():
|
||||||
|
return mysql.connector.connect(
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def now():
|
||||||
|
return datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _exec(conn, sql, args=()):
|
||||||
|
"""执行 SQL,自动将 ? 转为 MySQL 的 %s"""
|
||||||
|
cur = conn.cursor(dictionary=True)
|
||||||
|
cur.execute(sql.replace("?", "%s"), args)
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
def rows(conn, sql, args=()):
|
||||||
|
cur = _exec(conn, sql, args)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def one(conn, sql, args=()):
|
||||||
|
cur = _exec(conn, sql, args)
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
return row
|
||||||
1151
backend/flask_app.py
1151
backend/flask_app.py
File diff suppressed because it is too large
Load Diff
105
backend/helpers.py
Normal file
105
backend/helpers.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# helpers.py — 业务查询辅助函数
|
||||||
|
# 依赖:db.py;被 routes.py 和 migrations/seed_data.py 调用
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from db import _exec, rows, one
|
||||||
|
|
||||||
|
|
||||||
|
def add_file_index(conn, module, owner_id, owner_version, category, path, external=True):
|
||||||
|
path = Path(path)
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
_exec(conn,
|
||||||
|
"""INSERT INTO file_assets
|
||||||
|
(module,owner_id,owner_version,file_category,file_name,file_type,file_size,file_path,is_external)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(module, owner_id, owner_version, category, path.name, path.suffix.lower().lstrip("."), path.stat().st_size, str(path), 1 if external else 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
# 批量查 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="科普·无界"):
|
||||||
|
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=? AND status='已签约'",
|
||||||
|
[tenant])
|
||||||
|
|
||||||
|
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:
|
||||||
|
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,
|
||||||
|
"gross": gross,
|
||||||
|
"sign": sign, "payment": payment, "cost": cost,
|
||||||
|
})
|
||||||
|
return data
|
||||||
@@ -17,7 +17,7 @@ def run_migrations():
|
|||||||
"""
|
"""
|
||||||
from migrations.tables import migrate_create_tables
|
from migrations.tables import migrate_create_tables
|
||||||
from migrations.columns import migrate_add_columns
|
from migrations.columns import migrate_add_columns
|
||||||
from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant, migrate_drop_product_fields
|
from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant, migrate_drop_product_fields, migrate_fix_service_fee_standard
|
||||||
from migrations.seed import migrate_seed_users, migrate_seed_demo_data
|
from migrations.seed import migrate_seed_users, migrate_seed_demo_data
|
||||||
|
|
||||||
migrate_create_tables()
|
migrate_create_tables()
|
||||||
@@ -25,5 +25,6 @@ def run_migrations():
|
|||||||
migrate_fix_task_status()
|
migrate_fix_task_status()
|
||||||
migrate_rename_tenant()
|
migrate_rename_tenant()
|
||||||
migrate_drop_product_fields()
|
migrate_drop_product_fields()
|
||||||
|
migrate_fix_service_fee_standard()
|
||||||
migrate_seed_users()
|
migrate_seed_users()
|
||||||
migrate_seed_demo_data()
|
migrate_seed_demo_data()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
def _add_column_if_missing(conn, table, column, ddl):
|
def _add_column_if_missing(conn, table, column, ddl):
|
||||||
"""检查列是否存在,不存在才加(幂等)"""
|
"""检查列是否存在,不存在才加(幂等)"""
|
||||||
from flask_app import _exec, mysql, logger
|
from db import _exec, mysql, logger
|
||||||
|
|
||||||
cur = conn.cursor(dictionary=True)
|
cur = conn.cursor(dictionary=True)
|
||||||
cur.execute(f"SHOW COLUMNS FROM {table} LIKE %s", (column,))
|
cur.execute(f"SHOW COLUMNS FROM {table} LIKE %s", (column,))
|
||||||
@@ -19,7 +19,7 @@ def _add_column_if_missing(conn, table, column, ddl):
|
|||||||
|
|
||||||
def migrate_add_columns():
|
def migrate_add_columns():
|
||||||
"""为老表补齐后续新增的字段"""
|
"""为老表补齐后续新增的字段"""
|
||||||
from flask_app import db
|
from db import db
|
||||||
|
|
||||||
conn = db()
|
conn = db()
|
||||||
try:
|
try:
|
||||||
@@ -75,6 +75,30 @@ def migrate_add_columns():
|
|||||||
_add_column_if_missing(conn, "project_finances", "total_paid",
|
_add_column_if_missing(conn, "project_finances", "total_paid",
|
||||||
"ALTER TABLE project_finances ADD COLUMN total_paid DOUBLE NOT NULL DEFAULT 0")
|
"ALTER TABLE project_finances ADD COLUMN total_paid DOUBLE NOT NULL DEFAULT 0")
|
||||||
|
|
||||||
|
# project_finances 项目基本信息扩展字段
|
||||||
|
_add_column_if_missing(conn, "project_finances", "project_code",
|
||||||
|
"ALTER TABLE project_finances ADD COLUMN project_code VARCHAR(50) NOT NULL DEFAULT ''")
|
||||||
|
_add_column_if_missing(conn, "project_finances", "start_date",
|
||||||
|
"ALTER TABLE project_finances ADD COLUMN start_date VARCHAR(30) NOT NULL DEFAULT ''")
|
||||||
|
_add_column_if_missing(conn, "project_finances", "end_date",
|
||||||
|
"ALTER TABLE project_finances ADD COLUMN end_date VARCHAR(30) NOT NULL DEFAULT ''")
|
||||||
|
_add_column_if_missing(conn, "project_finances", "task_type",
|
||||||
|
"ALTER TABLE project_finances ADD COLUMN task_type VARCHAR(100) NOT NULL DEFAULT ''")
|
||||||
|
_add_column_if_missing(conn, "project_finances", "task_count",
|
||||||
|
"ALTER TABLE project_finances ADD COLUMN task_count DOUBLE NOT NULL DEFAULT 0")
|
||||||
|
_add_column_if_missing(conn, "project_finances", "service_fee_standard",
|
||||||
|
"ALTER TABLE project_finances ADD COLUMN service_fee_standard DOUBLE NOT NULL DEFAULT 0")
|
||||||
|
_add_column_if_missing(conn, "project_finances", "project_manager",
|
||||||
|
"ALTER TABLE project_finances ADD COLUMN project_manager VARCHAR(100) NOT NULL DEFAULT ''")
|
||||||
|
_add_column_if_missing(conn, "project_finances", "task_data",
|
||||||
|
"ALTER TABLE project_finances ADD COLUMN task_data TEXT")
|
||||||
|
_add_column_if_missing(conn, "project_finances", "contact_name",
|
||||||
|
"ALTER TABLE project_finances ADD COLUMN contact_name VARCHAR(100) NOT NULL DEFAULT ''")
|
||||||
|
_add_column_if_missing(conn, "project_finances", "contact_phone",
|
||||||
|
"ALTER TABLE project_finances ADD COLUMN contact_phone VARCHAR(50) NOT NULL DEFAULT ''")
|
||||||
|
_add_column_if_missing(conn, "project_finances", "other_info",
|
||||||
|
"ALTER TABLE project_finances ADD COLUMN other_info VARCHAR(500) NOT NULL DEFAULT ''")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
print("[migrate] 加列迁移完成")
|
print("[migrate] 加列迁移完成")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
def migrate_fix_task_status():
|
def migrate_fix_task_status():
|
||||||
"""修正 project_tasks 中非法的 status 值"""
|
"""修正 project_tasks 中非法的 status 值"""
|
||||||
from flask_app import db, _exec, mysql, logger
|
from db import db, _exec, mysql, logger
|
||||||
|
|
||||||
conn = db()
|
conn = db()
|
||||||
try:
|
try:
|
||||||
@@ -28,7 +28,7 @@ def migrate_fix_task_status():
|
|||||||
|
|
||||||
def migrate_rename_tenant():
|
def migrate_rename_tenant():
|
||||||
"""工作台重命名:无界·无界 → 学会·无界"""
|
"""工作台重命名:无界·无界 → 学会·无界"""
|
||||||
from flask_app import db, _exec, mysql
|
from db import db, _exec, mysql
|
||||||
|
|
||||||
conn = db()
|
conn = db()
|
||||||
try:
|
try:
|
||||||
@@ -51,7 +51,7 @@ def migrate_rename_tenant():
|
|||||||
|
|
||||||
def migrate_drop_product_fields():
|
def migrate_drop_product_fields():
|
||||||
"""删除 product_versions 表的 owner / platform / feature_list 字段"""
|
"""删除 product_versions 表的 owner / platform / feature_list 字段"""
|
||||||
from flask_app import db, mysql
|
from db import db, mysql
|
||||||
|
|
||||||
conn = db()
|
conn = db()
|
||||||
try:
|
try:
|
||||||
@@ -71,3 +71,24 @@ def migrate_drop_product_fields():
|
|||||||
print(f"[migrate] 删除 {col} 失败: {e}")
|
print(f"[migrate] 删除 {col} 失败: {e}")
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_fix_service_fee_standard():
|
||||||
|
"""修正 project_finances.service_fee_standard 旧数据为默认值 5(5%)"""
|
||||||
|
from db import db
|
||||||
|
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE project_finances SET service_fee_standard = 5 "
|
||||||
|
"WHERE service_fee_standard = 0 OR service_fee_standard IS NULL "
|
||||||
|
"OR service_fee_standard NOT IN (5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25)"
|
||||||
|
)
|
||||||
|
affected = cur.rowcount
|
||||||
|
cur.close()
|
||||||
|
if affected:
|
||||||
|
conn.commit()
|
||||||
|
print(f"[migrate] project_finances: {affected} 条记录 service_fee_standard 修正为 5")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from datetime import date
|
|||||||
|
|
||||||
def migrate_seed_users():
|
def migrate_seed_users():
|
||||||
"""初始化默认用户和工作台权限(仅空库时执行)"""
|
"""初始化默认用户和工作台权限(仅空库时执行)"""
|
||||||
from flask_app import db, _exec, one, generate_password_hash
|
from db import db, _exec, one
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
conn = db()
|
conn = db()
|
||||||
try:
|
try:
|
||||||
@@ -42,7 +43,8 @@ def migrate_seed_users():
|
|||||||
|
|
||||||
def migrate_seed_demo_data():
|
def migrate_seed_demo_data():
|
||||||
"""填充初始示例数据(仅在空库时执行)"""
|
"""填充初始示例数据(仅在空库时执行)"""
|
||||||
from flask_app import db, one, seed_db
|
from db import db, one
|
||||||
|
from migrations.seed_data import seed_db
|
||||||
|
|
||||||
conn = db()
|
conn = db()
|
||||||
try:
|
try:
|
||||||
|
|||||||
127
backend/migrations/seed_data.py
Normal file
127
backend/migrations/seed_data.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# seed_data.py — 初始示例数据填充(仅在空库时执行一次)
|
||||||
|
# 从 flask_app.py 搬迁,被 migrations/seed.py 调用
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from db import db, _exec, one, WEIXIN_BASE
|
||||||
|
from helpers import add_file_index
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
def migrate_create_tables():
|
def migrate_create_tables():
|
||||||
"""确保所有业务表存在(幂等)"""
|
"""确保所有业务表存在(幂等)"""
|
||||||
from flask_app import db, _exec, mysql, logger
|
from db import db, _exec, mysql, logger
|
||||||
|
|
||||||
conn = db()
|
conn = db()
|
||||||
try:
|
try:
|
||||||
|
|||||||
661
backend/routes.py
Normal file
661
backend/routes.py
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
# routes.py — 全部 HTTP 路由(Blueprint)
|
||||||
|
# 依赖:flask, db.py, helpers.py, werkzeug
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, render_template, request, send_file, session, redirect
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
|
||||||
|
from db import db, now, _exec, rows, one, UPLOAD_DIR
|
||||||
|
from helpers import add_file_index, attach_common, monthly_finance
|
||||||
|
|
||||||
|
bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
|
ALL_TENANTS = ["总工作台", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学会·无界"]
|
||||||
|
|
||||||
|
TABLES = {
|
||||||
|
"sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]),
|
||||||
|
"proposals": ("business_proposals", ["customer_or_project_name", "version", "description", "status", "created_date", "proposal_type", "notes", "tenant"]),
|
||||||
|
"operations": ("operation_projects", ["project_name", "project_version", "project_type", "project_status", "current_stage", "owner", "target_customer", "customer_need", "expected_contract_amount", "expected_sign_date", "sign_probability", "next_action", "sop_stage", "execution_progress", "current_deliverable", "risks", "notes", "tenant"]),
|
||||||
|
"products": ("product_versions", ["product_name", "version", "version_goal", "priority", "start_date", "plan_date", "dev_done_date", "test_date", "launch_date", "status", "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", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- 鉴权装饰器 ----------
|
||||||
|
|
||||||
|
def login_required(f):
|
||||||
|
from functools import wraps
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if "user_id" not in session:
|
||||||
|
return jsonify({"error": "未登录"}), 401
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 认证路由 ----------
|
||||||
|
|
||||||
|
@bp.route("/login")
|
||||||
|
def login_page():
|
||||||
|
return render_template("login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/auth/login", methods=["POST"])
|
||||||
|
def auth_login():
|
||||||
|
data = request.get_json(force=True) or {}
|
||||||
|
username = data.get("username", "").strip()
|
||||||
|
password = data.get("password", "")
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
user = one(conn, "SELECT * FROM users WHERE username=?", (username,))
|
||||||
|
if not user or not check_password_hash(user["password_hash"], password):
|
||||||
|
return jsonify({"error": "用户名或密码错误"}), 401
|
||||||
|
session["user_id"] = user["id"]
|
||||||
|
session["username"] = user["username"]
|
||||||
|
session["display_name"] = user["display_name"]
|
||||||
|
session["role"] = user["role"]
|
||||||
|
if user["role"] == "admin":
|
||||||
|
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]
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"user": {"id": user["id"], "username": user["username"], "display_name": user["display_name"], "role": user["role"]},
|
||||||
|
"tenants": session["tenants"],
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/auth/logout", methods=["POST"])
|
||||||
|
def auth_logout():
|
||||||
|
session.clear()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/auth/me")
|
||||||
|
def auth_me():
|
||||||
|
if "user_id" not in session:
|
||||||
|
return jsonify({"logged_in": False})
|
||||||
|
tenants = session.get("tenants", [])
|
||||||
|
if "总工作台" not in tenants:
|
||||||
|
tenants = ["总工作台"] + tenants
|
||||||
|
return jsonify({
|
||||||
|
"logged_in": True,
|
||||||
|
"user": {"id": session["user_id"], "username": session["username"], "display_name": session["display_name"], "role": session["role"]},
|
||||||
|
"tenants": tenants,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 账号管理 ----------
|
||||||
|
|
||||||
|
@bp.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()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.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()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/users/<int:uid>", 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))
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/users/<int:uid>", 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
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/tenants")
|
||||||
|
def list_tenants():
|
||||||
|
return jsonify(ALL_TENANTS)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 首页 + bootstrap ----------
|
||||||
|
|
||||||
|
@bp.route("/")
|
||||||
|
def index():
|
||||||
|
if "user_id" not in session:
|
||||||
|
return redirect("/login")
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/bootstrap")
|
||||||
|
def bootstrap():
|
||||||
|
if "user_id" not in session:
|
||||||
|
return jsonify({"error": "未登录"}), 401
|
||||||
|
tenant = request.args.get("tenant", session.get("tenants", ["科普·无界"])[0])
|
||||||
|
allowed = session.get("tenants", [])
|
||||||
|
if "总工作台" not in allowed:
|
||||||
|
allowed = ["总工作台"] + allowed
|
||||||
|
if tenant not in allowed:
|
||||||
|
tenant = allowed[0]
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
# 总工作台:聚合所有工作台的首页数据
|
||||||
|
if tenant == "总工作台":
|
||||||
|
real_tenants = [t for t in allowed if t != "总工作台"]
|
||||||
|
all_metrics = []
|
||||||
|
all_monthly = []
|
||||||
|
all_recent = []
|
||||||
|
for t in real_tenants:
|
||||||
|
t_pfs = rows(conn, "SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", [t])
|
||||||
|
t_ops = attach_common(conn, "operations", rows(conn, "SELECT * FROM operation_projects WHERE tenant=? ORDER BY id ASC", [t]))
|
||||||
|
t_sales = attach_common(conn, "sales", rows(conn, "SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", [t]))
|
||||||
|
t_products = attach_common(conn, "products", rows(conn, "SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", [t]))
|
||||||
|
t_proposals = attach_common(conn, "proposals", rows(conn, "SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", [t]))
|
||||||
|
t_signed_pfs = [x for x in t_pfs if x["status"] == "已签约"]
|
||||||
|
def t_parse_budget(pf):
|
||||||
|
try:
|
||||||
|
budget = json.loads(pf.get("budget_data") or "[]")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
budget = []
|
||||||
|
return {(b.get("month") or "").replace("-", "_"): b for b in budget}
|
||||||
|
t_bm = {pf["id"]: t_parse_budget(pf) for pf in t_pfs}
|
||||||
|
def t_sum_budget(field, months_range):
|
||||||
|
total = 0
|
||||||
|
for pf in t_pfs:
|
||||||
|
bm = t_bm.get(pf["id"], {})
|
||||||
|
for m in months_range:
|
||||||
|
b = bm.get(f"2026_{m:02d}")
|
||||||
|
if b:
|
||||||
|
total += float(b.get(field) or 0)
|
||||||
|
return total
|
||||||
|
_now_month = date.today().month
|
||||||
|
_q_start = ((_now_month - 1) // 3) * 3 + 1
|
||||||
|
_q_range = range(_q_start, _q_start + 3)
|
||||||
|
_q_months = [f"2026-{m:02d}" for m in _q_range]
|
||||||
|
_prev_q_start = ((_now_month - 4) // 3) * 3 + 1
|
||||||
|
_prev_q_range = range(max(_prev_q_start, 1), _prev_q_start + 3)
|
||||||
|
_prev_q_months = [f"2026-{m:02d}" for m in _prev_q_range]
|
||||||
|
_prev_month = _now_month - 1 if _now_month > 1 else None
|
||||||
|
all_metrics.append({
|
||||||
|
"total_projects": len(t_signed_pfs),
|
||||||
|
"total_proposals": len(t_ops),
|
||||||
|
"total_products": len(t_proposals),
|
||||||
|
"upcoming_products": len(t_products),
|
||||||
|
"signed_amount": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约"),
|
||||||
|
"signed_annual": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约"),
|
||||||
|
"signed_q2": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in _q_months),
|
||||||
|
"signed_month": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] == f"2026-{_now_month:02d}"),
|
||||||
|
"signed_prev_q": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in _prev_q_months),
|
||||||
|
"signed_prev_month": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] == f"2026-{_prev_month:02d}") if _prev_month else 0,
|
||||||
|
"revenue_annual": t_sum_budget("rev", range(1, 13)),
|
||||||
|
"revenue_q2": t_sum_budget("rev", _q_range),
|
||||||
|
"monthly_revenue": t_sum_budget("rev", [_now_month]),
|
||||||
|
"revenue_prev_q": t_sum_budget("rev", _prev_q_range),
|
||||||
|
"revenue_prev_month": t_sum_budget("rev", [_prev_month]) if _prev_month else 0,
|
||||||
|
"gross_annual": t_sum_budget("gross", range(1, 13)),
|
||||||
|
"gross_q2": t_sum_budget("gross", _q_range),
|
||||||
|
"monthly_net_profit": t_sum_budget("gross", [_now_month]),
|
||||||
|
"gross_prev_q": t_sum_budget("gross", _prev_q_range),
|
||||||
|
"gross_prev_month": t_sum_budget("gross", [_prev_month]) if _prev_month else 0,
|
||||||
|
"payment_annual": t_sum_budget("payment", range(1, 13)),
|
||||||
|
"payment_q2": t_sum_budget("payment", _q_range),
|
||||||
|
"payment_month": t_sum_budget("payment", [_now_month]),
|
||||||
|
"payment_prev_q": t_sum_budget("payment", _prev_q_range),
|
||||||
|
"payment_prev_month": t_sum_budget("payment", [_prev_month]) if _prev_month else 0,
|
||||||
|
"cost_annual": t_sum_budget("cost", range(1, 13)),
|
||||||
|
"cost_q2": t_sum_budget("cost", _q_range),
|
||||||
|
"cost_month": t_sum_budget("cost", [_now_month]),
|
||||||
|
"cost_prev_q": t_sum_budget("cost", _prev_q_range),
|
||||||
|
"cost_prev_month": t_sum_budget("cost", [_prev_month]) if _prev_month else 0,
|
||||||
|
})
|
||||||
|
all_monthly.append(monthly_finance(conn, t))
|
||||||
|
all_recent.extend(rows(conn, "SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 4", [t]))
|
||||||
|
agg = {}
|
||||||
|
for key in ["total_projects","total_proposals","total_products","upcoming_products","signed_amount","signed_annual","signed_q2","signed_month","signed_prev_q","signed_prev_month","revenue_annual","revenue_q2","monthly_revenue","revenue_prev_q","revenue_prev_month","gross_annual","gross_q2","monthly_net_profit","gross_prev_q","gross_prev_month","payment_annual","payment_q2","payment_month","payment_prev_q","payment_prev_month","cost_annual","cost_q2","cost_month","cost_prev_q","cost_prev_month"]:
|
||||||
|
agg[key] = sum(m.get(key, 0) for m in all_metrics)
|
||||||
|
merged_monthly = []
|
||||||
|
for i in range(12):
|
||||||
|
m = {"month": all_monthly[0][i]["month"] if all_monthly and len(all_monthly[0]) > i else f"2026-{i+1:02d}"}
|
||||||
|
for field in ["revenue","gross","payment","cost","sign"]:
|
||||||
|
m[field] = sum(tl[i][field] if i < len(tl) else 0 for tl in all_monthly)
|
||||||
|
merged_monthly.append(m)
|
||||||
|
summary = {
|
||||||
|
"project_name": "总工作台",
|
||||||
|
"metrics": agg,
|
||||||
|
"recent": sorted(all_recent, key=lambda x: x.get("id", 0), reverse=True)[:8],
|
||||||
|
"risks": [],
|
||||||
|
}
|
||||||
|
return jsonify({"summary": summary, "sales": [], "proposals": [], "operations": [], "products": [], "finance": [], "projectFinances": [], "financeMonthly": merged_monthly, "tasks": [], "tenant": tenant, "tenants": allowed})
|
||||||
|
|
||||||
|
def q(sql, *args):
|
||||||
|
return rows(conn, sql, args)
|
||||||
|
sales = attach_common(conn, "sales", q("SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", tenant))
|
||||||
|
proposals = attach_common(conn, "proposals", q("SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", tenant))
|
||||||
|
operations = attach_common(conn, "operations", q("SELECT * FROM operation_projects WHERE tenant=? ORDER BY id 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)
|
||||||
|
signed_pfs = [x for x in pfs if x["status"] == "已签约"]
|
||||||
|
|
||||||
|
def parse_budget(pf):
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
|
_now_month = date.today().month
|
||||||
|
_q_start = ((_now_month - 1) // 3) * 3 + 1
|
||||||
|
_q_range = range(_q_start, _q_start + 3)
|
||||||
|
_prev_q_start = ((_now_month - 4) // 3) * 3 + 1
|
||||||
|
_prev_q_range = range(max(_prev_q_start, 1), _prev_q_start + 3)
|
||||||
|
_prev_month = _now_month - 1 if _now_month > 1 else None
|
||||||
|
rev_annual = sum_budget("rev", range(1, 13))
|
||||||
|
gross_annual = sum_budget("gross", range(1, 13))
|
||||||
|
rev_q2 = sum_budget("rev", _q_range)
|
||||||
|
gross_q2 = sum_budget("gross", _q_range)
|
||||||
|
rev_month = sum_budget("rev", [_now_month])
|
||||||
|
gross_month = sum_budget("gross", [_now_month])
|
||||||
|
rev_prev_q = sum_budget("rev", _prev_q_range)
|
||||||
|
gross_prev_q = sum_budget("gross", _prev_q_range)
|
||||||
|
rev_prev_month = sum_budget("rev", [_prev_month]) if _prev_month else 0
|
||||||
|
gross_prev_month = sum_budget("gross", [_prev_month]) if _prev_month else 0
|
||||||
|
payment_annual = sum_budget("payment", range(1, 13))
|
||||||
|
cost_annual = sum_budget("cost", range(1, 13))
|
||||||
|
payment_q2 = sum_budget("payment", _q_range)
|
||||||
|
cost_q2 = sum_budget("cost", _q_range)
|
||||||
|
payment_month = sum_budget("payment", [_now_month])
|
||||||
|
cost_month = sum_budget("cost", [_now_month])
|
||||||
|
payment_prev_q = sum_budget("payment", _prev_q_range)
|
||||||
|
cost_prev_q = sum_budget("cost", _prev_q_range)
|
||||||
|
payment_prev_month = sum_budget("payment", [_prev_month]) if _prev_month else 0
|
||||||
|
cost_prev_month = sum_budget("cost", [_prev_month]) if _prev_month else 0
|
||||||
|
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("已签约")
|
||||||
|
signed_annual = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约")
|
||||||
|
_q_months = [f"2026-{m:02d}" for m in _q_range]
|
||||||
|
signed_q2 = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in _q_months)
|
||||||
|
signed_month = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] == f"2026-{_now_month:02d}")
|
||||||
|
_prev_q_months = [f"2026-{m:02d}" for m in _prev_q_range]
|
||||||
|
signed_prev_q = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in _prev_q_months)
|
||||||
|
signed_prev_month = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] == f"2026-{_prev_month:02d}") if _prev_month else 0
|
||||||
|
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 = {
|
||||||
|
"project_name": "科普(慰心斋)",
|
||||||
|
"metrics": {
|
||||||
|
"p0_customers": len([x for x in sales if x["priority"] == "P0"]),
|
||||||
|
"active_sales": len([x for x in sales if x["status"] in ["待跟进", "跟进中", "方案中", "商务谈判"]]),
|
||||||
|
"execution_projects": len([x for x in operations if x["project_type"] == "execution"]),
|
||||||
|
"risk_projects": len([x for x in operations if x["project_status"] == "有风险" or x["risks"]]),
|
||||||
|
"monthly_revenue": rev_month,
|
||||||
|
"monthly_net_profit": gross_month,
|
||||||
|
"monthly_gross": gross_month,
|
||||||
|
"upcoming_products": len(products),
|
||||||
|
"total_projects": len(signed_pfs),
|
||||||
|
"total_proposals": len(operations),
|
||||||
|
"total_products": len(proposals),
|
||||||
|
"signed_amount": signed_amount,
|
||||||
|
"signed_annual": signed_annual,
|
||||||
|
"signed_q2": signed_q2,
|
||||||
|
"signed_month": signed_month,
|
||||||
|
"signed_prev_q": signed_prev_q,
|
||||||
|
"signed_prev_month": signed_prev_month,
|
||||||
|
"pipeline_amount": pipeline_amount,
|
||||||
|
"revenue_annual": rev_annual,
|
||||||
|
"revenue_q2": rev_q2,
|
||||||
|
"revenue_prev_q": rev_prev_q,
|
||||||
|
"revenue_prev_month": rev_prev_month,
|
||||||
|
"gross_annual": gross_annual,
|
||||||
|
"gross_q2": gross_q2,
|
||||||
|
"gross_prev_q": gross_prev_q,
|
||||||
|
"gross_prev_month": gross_prev_month,
|
||||||
|
"payment_annual": payment_annual,
|
||||||
|
"payment_q2": payment_q2,
|
||||||
|
"payment_month": payment_month,
|
||||||
|
"payment_prev_q": payment_prev_q,
|
||||||
|
"payment_prev_month": payment_prev_month,
|
||||||
|
"cost_annual": cost_annual,
|
||||||
|
"cost_q2": cost_q2,
|
||||||
|
"cost_month": cost_month,
|
||||||
|
"cost_prev_q": cost_prev_q,
|
||||||
|
"cost_prev_month": cost_prev_month,
|
||||||
|
"signed_not_executed": signed_not_executed,
|
||||||
|
},
|
||||||
|
"recent": q("SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 8", tenant),
|
||||||
|
"risks": [{"title": "执行提醒", "content": x["next_action"]} for x in operations if x["next_action"]][:5],
|
||||||
|
}
|
||||||
|
return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "projectFinances": pfs, "financeMonthly": monthly_finance(conn, tenant), "tasks": tasks, "tenant": tenant, "tenants": allowed})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 通用 CRUD ----------
|
||||||
|
|
||||||
|
@bp.route("/api/<resource>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def create_resource(resource):
|
||||||
|
if resource not in TABLES:
|
||||||
|
return jsonify({"error": "unknown resource"}), 404
|
||||||
|
table, cols = TABLES[resource]
|
||||||
|
payload = request.get_json(force=True).get("data", {})
|
||||||
|
if resource == "tasks":
|
||||||
|
valid_statuses = ["未开始", "进行中", "已结束"]
|
||||||
|
if not payload.get("status") or payload["status"] not in valid_statuses:
|
||||||
|
payload["status"] = "未开始"
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
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})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/<resource>/<int:item_id>", methods=["PUT", "DELETE"])
|
||||||
|
@login_required
|
||||||
|
def update_resource(resource, item_id):
|
||||||
|
if resource not in TABLES:
|
||||||
|
return jsonify({"error": "unknown resource"}), 404
|
||||||
|
table, cols = TABLES[resource]
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
if request.method == "DELETE":
|
||||||
|
_exec(conn, f"DELETE FROM {table} WHERE id=?", (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"] = "未开始"
|
||||||
|
if resource == "products":
|
||||||
|
cur = _exec(conn, f"SELECT start_date FROM {table} WHERE id=?", (item_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
current_start = (row or {}).get("start_date", "") or ""
|
||||||
|
new_start = payload.get("start_date", current_start)
|
||||||
|
if "start_date" in payload and not new_start:
|
||||||
|
return jsonify({"error": "启动时间为必填项"}), 400
|
||||||
|
date_fields = ["plan_date", "dev_done_date", "test_date", "launch_date"]
|
||||||
|
for f in date_fields:
|
||||||
|
if f in payload and payload[f] and new_start and payload[f] < new_start:
|
||||||
|
labels = {"plan_date": "产品方案", "dev_done_date": "研发完成", "test_date": "测试完成", "launch_date": "上线时间"}
|
||||||
|
return jsonify({"error": f"{labels[f]}不能早于启动时间({new_start})"}), 400
|
||||||
|
update_cols = [col for col in cols if col in payload]
|
||||||
|
if update_cols:
|
||||||
|
_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],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- followups ----------
|
||||||
|
|
||||||
|
@bp.route("/api/followups/<target_type>/<int:target_id>")
|
||||||
|
def list_followups(target_type, target_id):
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
fups = rows(conn,
|
||||||
|
"SELECT * FROM follow_up_records WHERE target_type=? AND target_id=? ORDER BY followed_at DESC, id DESC",
|
||||||
|
(target_type, target_id))
|
||||||
|
return jsonify(fups)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@bp.route("/api/followups/<target_type>/<int:target_id>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def add_followup(target_type, target_id):
|
||||||
|
payload = request.get_json(force=True).get("data", {})
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
_exec(conn,
|
||||||
|
"""INSERT INTO follow_up_records
|
||||||
|
(target_type,target_id,followed_at,follower,follow_up_method,content,next_action,next_follow_up_at,tenant)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
payload.get("followed_at") or date.today().isoformat(),
|
||||||
|
payload.get("follower") or "慰心",
|
||||||
|
payload.get("follow_up_method") or "记录",
|
||||||
|
payload.get("content") or "",
|
||||||
|
payload.get("next_action") or "",
|
||||||
|
payload.get("next_follow_up_at") or "",
|
||||||
|
payload.get("tenant") or "科普·无界",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/followups/<int:followup_id>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def delete_followup(followup_id):
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
cur = _exec(conn, "DELETE FROM follow_up_records WHERE id=?", (followup_id,))
|
||||||
|
conn.commit()
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- batch sort ----------
|
||||||
|
|
||||||
|
@bp.route("/api/tasks/batch-sort", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def batch_sort_tasks():
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
items = request.get_json(force=True).get("items", [])
|
||||||
|
for item in items:
|
||||||
|
_exec(conn, "UPDATE project_tasks SET sort_order=? WHERE id=?", (item["sort_order"], item["id"]))
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.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()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- files ----------
|
||||||
|
|
||||||
|
@bp.route("/api/files/upload", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def upload_file():
|
||||||
|
file = request.files["file"]
|
||||||
|
module = request.form["module"]
|
||||||
|
owner_id = int(request.form["owner_id"])
|
||||||
|
owner_version = request.form.get("owner_version", "")
|
||||||
|
category = request.form.get("file_category", "")
|
||||||
|
folder = UPLOAD_DIR / module / str(owner_id)
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = folder / file.filename
|
||||||
|
file.save(target)
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
add_file_index(conn, module, owner_id, owner_version, category, target, external=False)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/files/<int:file_id>/content")
|
||||||
|
def file_content(file_id):
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
asset = one(conn, "SELECT * FROM file_assets WHERE id=?", (file_id,))
|
||||||
|
if not asset:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
path = Path(asset["file_path"])
|
||||||
|
if not path.exists():
|
||||||
|
return jsonify({"error": "missing"}), 404
|
||||||
|
return send_file(path, as_attachment=request.args.get("inline") == "false", download_name=asset["file_name"])
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/files/<int:file_id>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def delete_file(file_id):
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
asset = one(conn, "SELECT * FROM file_assets WHERE id=?", (file_id,))
|
||||||
|
if not asset:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
path = Path(asset["file_path"])
|
||||||
|
if path.exists() and str(UPLOAD_DIR) in str(path.resolve()):
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
_exec(conn, "DELETE FROM file_assets WHERE id=?", (file_id,))
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- health ----------
|
||||||
|
|
||||||
|
@bp.route("/api/health")
|
||||||
|
def health():
|
||||||
|
return jsonify({"ok": True, "service": "opc-manager"})
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
// finance.js — 经营管理(财务)模块
|
// finance.js — 经营管理(财务)模块
|
||||||
|
|
||||||
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")} 元`;
|
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")} 元`;
|
||||||
|
const moneyWan = (v) => `${(Number(v || 0) / 10000).toFixed(1)} 万`;
|
||||||
|
|
||||||
function renderFinance() {
|
function renderFinance() {
|
||||||
const pfs = state.data.projectFinances || [];
|
const pfs = state.data.projectFinances || [];
|
||||||
const ops = state.data.operations || [];
|
const ops = state.data.operations || [];
|
||||||
const fmTypesByTenant = {
|
const fmTypesByTenant = {
|
||||||
"科普·无界": ["科普音频","科普视频","科普文章","全品类科普","调研问卷"],
|
"科普·无界": ["科普音频","科普视频","科普文章","科普专访","患教会","全品类科普","调研问卷"],
|
||||||
"科研·无界": ["真实世界研究","调研问卷","病例征集","患者招募"],
|
"科研·无界": ["真实世界研究","调研问卷","病例征集","患者招募"],
|
||||||
"医患·无界": ["医患运营","患者管理","患教会","创新支付","电商","其他"],
|
"医患·无界": ["医患运营","患者管理","患教会","创新支付","电商","其他"],
|
||||||
};
|
};
|
||||||
@@ -73,7 +74,7 @@ function renderFinance() {
|
|||||||
const budget = JSON.parse(pf.budget_data || "[]");
|
const budget = JSON.parse(pf.budget_data || "[]");
|
||||||
budget.forEach(b => { budgetMap[(b.month || "").replace("-", "_")] = b; });
|
budget.forEach(b => { budgetMap[(b.month || "").replace("-", "_")] = b; });
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
const isRevView = state.finView !== "cashflow" && state.finView !== "overview" && state.finView !== "monthly";
|
const isRevView = state.finView !== "cashflow" && state.finView !== "overview" && state.finView !== "monthly" && state.finView !== "quarterly";
|
||||||
const mCols = months.map(m => {
|
const mCols = months.map(m => {
|
||||||
const b = budgetMap[m] || {};
|
const b = budgetMap[m] || {};
|
||||||
if (isRevView) {
|
if (isRevView) {
|
||||||
@@ -99,46 +100,61 @@ function renderFinance() {
|
|||||||
})();
|
})();
|
||||||
const sm = pf.sign_month || "";
|
const sm = pf.sign_month || "";
|
||||||
const signMonthCell = `<td class="p-2 text-center text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
const signMonthCell = `<td class="p-2 text-center text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
||||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center">${esc(pf.customer_name)}</td><td class="p-2 text-sm text-center">${esc(pf.business_type)}</td><td class="p-2 text-sm text-center">${pf.status === "已签约" ? badge("已签约") : pf.status === "流程中" ? badge("流程中","blue") : badge("待签约","amber")}</td>${signMonthCell}<td class="p-2 text-center text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center">${esc(pf.customer_name)}</td>${signMonthCell}<td class="p-2 text-center text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const finHeaderBase = `<button class="btn btn-sm ${state.finView === 'overview' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="overview" onclick="setFinView('overview')">总视图</button><button class="btn btn-sm ${state.finView === 'monthly' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="monthly" onclick="setFinView('monthly')">月度</button><button class="btn btn-sm ${state.finView === 'quarterly' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="quarterly" onclick="setFinView('quarterly')">季度</button><span class="text-slate-300 mx-0.5">|</span><span class="text-xs text-slate-400">筛选:</span><span class="text-xs text-slate-500">状态:</span><select onchange="state.finFilter=this.value;renderFinance()" class="text-xs font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="已签约" ${state.finFilter==='已签约'?'selected':''}>已签约 (${pfs.filter(x=>x.status==='已签约').length})</option><option value="流程中" ${state.finFilter==='流程中'?'selected':''}>流程中 (${pfs.filter(x=>x.status==='流程中').length})</option><option value="待签约" ${state.finFilter==='待签约'?'selected':''}>待签约 (${pfs.filter(x=>x.status==='待签约').length})</option></select>`;
|
||||||
|
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`;
|
||||||
|
|
||||||
document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
|
document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
|
||||||
<div class="grid grid-cols-4 gap-3">
|
<div class="grid grid-cols-4 gap-3">
|
||||||
${[["已签项目","" + signed.length,"file-check-2"],["签约金额",moneyInt(sumSign),"coins"],["待签项目","" + pending.length,"file-question"],["待签金额",moneyInt(sumPending),"hourglass"]].map(([l,v,icon]) => `<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${l}</span><strong class="mt-2 block text-2xl">${v}</strong></div>`).join("")}
|
${[["已签项目","" + signed.length,"file-check-2"],["签约金额",moneyWan(sumSign),"coins"],["待签项目","" + pending.length,"file-question"],["待签金额",moneyWan(sumPending),"hourglass"]].map(([l,v,icon]) => `<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${l}</span><strong class="mt-2 block text-2xl">${v}</strong></div>`).join("")}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-5 gap-3">
|
<div class="grid grid-cols-5 gap-3">
|
||||||
${[["本月确收",moneyInt(thisMonthRev),"trending-up"],["本月毛利",moneyInt(thisMonthGross),"percent"],["本月回款",moneyInt(monthPayment),"wallet"],["本月应付",moneyInt(monthCost),"receipt"],["本月现金流",moneyInt(monthCashflow),"repeat"]].map(([l,v,icon]) => `<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${l}</span><strong class="mt-2 block text-2xl">${v}</strong></div>`).join("")}
|
${[["本月确收",moneyInt(thisMonthRev),"trending-up"],["本月毛利",moneyInt(thisMonthGross),"percent"],["本月回款",moneyInt(monthPayment),"wallet"],["本月应付",moneyInt(monthCost),"receipt"],["本月现金流",moneyInt(monthCashflow),"repeat"]].map(([l,v,icon]) => `<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${l}</span><strong class="mt-2 block text-2xl">${v}</strong></div>`).join("")}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center"><div class="flex items-center gap-1" id="finViewToggle"><button class="btn btn-sm ${state.finView === 'overview' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="overview" onclick="setFinView('overview')" title="总视图"><i data-lucide="layout-dashboard" style="width:16px;height:16px"></i><span class="text-xs ml-1">总视图</span></button><button class="btn btn-sm ${state.finView === 'monthly' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="monthly" onclick="setFinView('monthly')" title="月度视图"><i data-lucide="calendar-days" style="width:16px;height:16px"></i><span class="text-xs ml-1">月度视图</span></button></div><button class="btn btn-primary btn-sm" onclick="openFinanceModal()"><i data-lucide="plus"></i>新增财务项目</button></div>
|
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
|
||||||
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-5xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
|
|
||||||
<div class="finance-tabs">
|
<div class="finance-tabs">
|
||||||
<button class="finance-tab active" data-tab="info" onclick="switchFinanceTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
|
<button class="finance-tab active" data-tab="info" onclick="switchFinanceTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
|
||||||
<button class="finance-tab" data-tab="budget" onclick="switchFinanceTab('budget')"><i data-lucide="calendar" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>月度流水</button>
|
<button class="finance-tab" data-tab="budget" onclick="switchFinanceTab('budget')"><i data-lucide="calendar" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>项目流水</button>
|
||||||
|
<button class="finance-tab" data-tab="exec" onclick="switchFinanceTab('exec')"><i data-lucide="play-circle" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>执行信息</button>
|
||||||
|
<button class="finance-tab" data-tab="tasks" onclick="switchFinanceTab('tasks')"><i data-lucide="list-checks" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>任务管理</button>
|
||||||
|
<button class="finance-tab" data-tab="activity" onclick="switchFinanceTab('activity')"><i data-lucide="message-square" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>活动与跟进</button>
|
||||||
</div>
|
</div>
|
||||||
<form onsubmit="createFinance(event)" class="p-8 grid gap-6"><input type="hidden" name="pf_id" id="pf-id-input" value="">
|
<form onsubmit="createFinance(event)" class="finance-form" novalidate><input type="hidden" name="pf_id" id="pf-id-input" value="">
|
||||||
|
<div class="finance-tab-body">
|
||||||
<div id="financeTabInfo">
|
<div id="financeTabInfo">
|
||||||
<div class="grid grid-cols-2 gap-5">
|
<div class="grid gap-4">
|
||||||
<div class="fin-field-group">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<p class="fin-section-label">项目信息</p>
|
<label class="block"><span class="fin-label">部门</span><input type="hidden" name="project_id" value="${state.tenant}"><input class="form-ctrl bg-slate-50 cursor-not-allowed" value="${state.tenant}" disabled></label>
|
||||||
<div class="grid gap-4">
|
<label class="block"><span class="fin-label">项目编号</span><input name="project_code" class="form-ctrl" placeholder="如:KP-2026-001"></label>
|
||||||
<label class="block"><span class="fin-label">部门</span><input type="hidden" name="project_id" value="${state.tenant}"><input class="form-ctrl bg-slate-50 cursor-not-allowed" value="${state.tenant}" disabled></label>
|
<label class="block"><span class="fin-label">项目名称 <span class="text-red-500">*</span></span><input name="customer_name" required class="form-ctrl" placeholder="请输入项目名称"></label>
|
||||||
<label class="block"><span class="fin-label">业务类型</span><select name="business_type" class="form-ctrl bg-white">${fmTypes.map(t => `<option>${t}</option>`).join("")}</select></label>
|
|
||||||
<label class="block"><span class="fin-label">项目名称 <span class="text-red-500">*</span></span><input name="customer_name" required class="form-ctrl" placeholder="请输入项目名称"></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="fin-field-group">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<p class="fin-section-label">合同信息</p>
|
<label class="block"><span class="fin-label">签约金额(元) <span class="text-red-500">*</span></span><input name="sign_amount" class="form-ctrl" placeholder="必须大于 0"></label>
|
||||||
<div class="grid gap-4">
|
<label class="block"><span class="fin-label">签约月份 <span class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions('')}</select></label>
|
||||||
<label class="block"><span class="fin-label">签约金额(元) <span class="text-red-500">*</span></span><input name="sign_amount" type="number" step="0.01" min="0.01" required class="form-ctrl" placeholder="必须大于 0"></label>
|
<label class="block"><span class="fin-label">项目状态</span><select name="status" class="form-ctrl bg-white"><option>已签约</option><option>流程中</option><option>待签约</option></select></label>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
</div>
|
||||||
<label class="block"><span class="fin-label">签约月份 <span class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions('')}</select></label>
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<label class="block"><span class="fin-label">项目状态</span><select name="status" class="form-ctrl bg-white"><option>已签约</option><option>流程中</option><option>待签约</option></select></label>
|
<label class="block"><span class="fin-label">商务负责人 <span class="text-red-500">*</span></span><input name="sales_person" required class="form-ctrl" placeholder="请输入商务负责人"></label>
|
||||||
</div>
|
<label class="block"><span class="fin-label">经营负责人 <span class="text-red-500">*</span></span><input name="owner" required class="form-ctrl" placeholder="请输入经营负责人"></label>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<label class="block"><span class="fin-label">业务联系人</span><input name="contact_name" class="form-ctrl" placeholder="请输入业务联系人"></label>
|
||||||
<label class="block"><span class="fin-label">商务负责人 <span class="text-red-500">*</span></span><input name="sales_person" required class="form-ctrl" placeholder="请输入商务负责人"></label>
|
</div>
|
||||||
<label class="block"><span class="fin-label">经营负责人 <span class="text-red-500">*</span></span><input name="owner" required class="form-ctrl" placeholder="请输入经营负责人"></label>
|
<div class="grid grid-cols-3 gap-3">
|
||||||
</div>
|
<label class="block"><span class="fin-label">联系电话</span><input name="contact_phone" class="form-ctrl" placeholder="请输入联系电话"></label>
|
||||||
</div>
|
<label class="block"><span class="fin-label">其他</span><input name="other_info" class="form-ctrl" placeholder="备注信息"></label>
|
||||||
|
</div>
|
||||||
|
<div class="block"><span class="fin-label">业务类型</span><div class="flex flex-wrap gap-2 mt-1" id="businessTypeChecks">${fmTypes.map(t => `<label class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-slate-200 cursor-pointer hover:border-blue-300 text-sm bt-chip" onclick="toggleBtChip(this)"><input type="checkbox" name="business_type[]" value="${t}" class="hidden"><span>${t}</span></label>`).join("")}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="financeTabExec" class="hidden">
|
||||||
|
<div class="fin-field-group">
|
||||||
|
<p class="fin-section-label">执行信息</p>
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<label class="block"><span class="fin-label">开始时间</span><input name="start_date" type="date" class="form-ctrl"></label>
|
||||||
|
<label class="block"><span class="fin-label">结束时间</span><input name="end_date" type="date" class="form-ctrl"></label>
|
||||||
|
<label class="block"><span class="fin-label">项目经理</span><input name="project_manager" class="form-ctrl" placeholder="请输入项目经理"></label>
|
||||||
|
<label class="block"><span class="fin-label">合同服务费标准</span><select name="service_fee_standard" class="form-ctrl bg-white">${Array.from({length:21},(_,i)=>{const v=i+5;return `<option value="${v}" ${v===5?'selected':''}>${v}%</option>`}).join("")}</select></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +187,37 @@ function renderFinance() {
|
|||||||
</table>
|
</table>
|
||||||
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addBudgetRow()"><i data-lucide="plus"></i>添加月份</button>
|
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addBudgetRow()"><i data-lucide="plus"></i>添加月份</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-3 pt-2"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closeFinanceModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>
|
<div id="financeTabTasks" class="hidden">
|
||||||
|
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="taskTable">
|
||||||
|
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">任务类型</th><th class="p-2.5 text-right font-medium text-slate-500">任务数量</th><th class="p-2.5 text-right font-medium text-slate-500">已执行</th><th class="p-2.5 text-right font-medium text-slate-500">差额</th><th class="p-2.5 text-right font-medium text-slate-500">单价</th><th class="p-2.5 text-right font-medium text-slate-500">执行金额</th><th class="p-2.5 text-right font-medium text-slate-500">未执行金额</th><th class="p-2.5 w-8"></th></tr></thead>
|
||||||
|
<tbody id="taskTbody"></tbody>
|
||||||
|
</table>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addTaskRow()"><i data-lucide="plus"></i>添加任务</button>
|
||||||
|
</div>
|
||||||
|
<div id="financeTabActivity" class="hidden">
|
||||||
|
<div class="grid gap-2" id="finActivityList"></div>
|
||||||
|
<div class="comment-box mt-3">
|
||||||
|
<div class="squire-toolbar">
|
||||||
|
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('bold')" title="加粗"><i data-lucide="bold"></i></button>
|
||||||
|
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('italic')" title="斜体"><i data-lucide="italic"></i></button>
|
||||||
|
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('underline')" title="下划线"><i data-lucide="underline"></i></button>
|
||||||
|
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('strikethrough')" title="删除线"><i data-lucide="strikethrough"></i></button>
|
||||||
|
<span class="squire-sep"></span>
|
||||||
|
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('makeUnorderedList')" title="无序列表"><i data-lucide="list"></i></button>
|
||||||
|
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('makeOrderedList')" title="有序列表"><i data-lucide="list-ordered"></i></button>
|
||||||
|
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('blockquote')" title="引用"><i data-lucide="quote"></i></button>
|
||||||
|
<span class="squire-sep"></span>
|
||||||
|
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('undo')" title="撤销"><i data-lucide="undo"></i></button>
|
||||||
|
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('redo')" title="重做"><i data-lucide="redo"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="squire-editor" id="squire_finance" placeholder="添加评论"></div>
|
||||||
|
<div class="comment-toolbar">
|
||||||
|
<span class="comment-hint">支持富文本编辑</span>
|
||||||
|
<button class="btn btn-primary btn-sm comment-submit" type="button" onclick="submitFinComment()">评论</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><div class="flex justify-end gap-3 pt-2 finance-form-actions"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closeFinanceModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>
|
||||||
${state.finView === 'monthly' ? (() => {
|
${state.finView === 'monthly' ? (() => {
|
||||||
const allPfs = pfs.filter(x => x.status === state.finFilter);
|
const allPfs = pfs.filter(x => x.status === state.finFilter);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -199,9 +245,45 @@ function renderFinance() {
|
|||||||
const costDiff = cost - paid;
|
const costDiff = cost - paid;
|
||||||
const cashflow = payment - paid;
|
const cashflow = payment - paid;
|
||||||
sumRev+=rev; sumPay+=payment; sumCost+=cost; sumPaid+=paid;
|
sumRev+=rev; sumPay+=payment; sumCost+=cost; sumPaid+=paid;
|
||||||
rows.push(`<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center">${esc(pf.customer_name)}</td><td class="p-2 text-sm text-center text-slate-500">${esc(pf.business_type)}</td><td class="p-2 text-sm text-center">${pf.status === '已签约' ? badge('已签约') : pf.status === '流程中' ? badge('流程中','blue') : badge('待签约','amber')}</td><td class="p-2 text-center text-blue-700 align-middle">${fmt(rev)}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(payment)}</td><td class="p-2 text-center align-middle">${fmtDiff(payDiff)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(paid)}</td><td class="p-2 text-center align-middle">${fmtDiff(costDiff)}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`);
|
rows.push(`<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center">${esc(pf.customer_name)}</td><td class="p-2 text-center text-sm">${pf.status==='已签约'?'<span class="text-green-600">已签约</span>':pf.status==='流程中'?'<span class="text-blue-600">流程中</span>':'<span class="text-amber-600">待签约</span>'}</td><td class="p-2 text-center text-blue-700 align-middle">${fmt(rev)}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(payment)}</td><td class="p-2 text-center align-middle">${fmtDiff(payDiff)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(paid)}</td><td class="p-2 text-center align-middle">${fmtDiff(costDiff)}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`);
|
||||||
});
|
});
|
||||||
return card(`<div class="flex items-center justify-between mb-3"><h3 class="font-bold text-slate-700">月度视图 <span class="text-slate-400 font-normal">(${allPfs.length} 项目)</span></h3></div><div class="flex items-center justify-between mb-3"><div class="flex gap-2">${[["已签约","已签约"],["流程中","流程中"],["待签约","待签约"]].map(([k,v]) => `<button class="btn btn-sm ${state.finFilter === k ? 'btn-primary' : 'btn-ghost'}" onclick="state.finFilter='${k}';renderFinance()">${v} (${pfs.filter(x=>x.status===k).length})</button>`).join("")}</div><label class="inline-flex items-center gap-1 text-sm text-slate-500 cursor-pointer relative"><i data-lucide="calendar" style="width:14px;height:14px"></i><select onchange="state.finMonth=this.value;renderFinance()" class="bg-transparent border-0 text-sm text-slate-600 font-medium cursor-pointer pr-5" style="appearance:none;-webkit-appearance:none;-moz-appearance:none;outline:none">${monthOpts}</select><i data-lucide="chevron-down" style="width:14px;height:14px;position:absolute;right:0;top:50%;transform:translateY(-50%);pointer-events:none"></i></label></div><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold align-middle">项目名称</th><th class="p-2 text-center font-semibold align-middle">类型</th><th class="p-2 text-center font-semibold align-middle">状态</th><th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款差额</th><th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th><th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th><th class="p-2 text-center font-semibold align-middle">应付差额</th><th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th></tr></thead><tbody>${rows.length ? rows.join("") : '<tr><td colspan="10" class="p-6 text-center text-slate-400">该月份暂无数据</td></tr>'}<tr class="border-t-2 border-slate-200 bg-slate-50 font-bold"><td class="p-2 text-center" colspan="3">合计</td><td class="p-2 text-center text-blue-700 align-middle">${money(sumRev)}</td><td class="p-2 text-center text-amber-700 align-middle">${money(sumPay)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumRev - sumPay)}</td><td class="p-2 text-center text-rose-700 align-middle">${money(sumCost)}</td><td class="p-2 text-center text-purple-700 align-middle">${money(sumPaid)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumCost - sumPaid)}</td><td class="p-2 text-center font-semibold align-middle ${sumPay - sumPaid >= 0 ? 'text-green-600' : 'text-red-600'}">${money(sumPay - sumPaid)}</td></tr></tbody></table></div>`, "p-4");
|
return card(`<div class="flex justify-between items-center mb-3"><div class="flex items-center gap-2">${finHeaderBase}<span class="text-xs text-slate-500">月份:</span><select onchange="state.finMonth=this.value;renderFinance()" class="text-xs font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px">${monthOpts}</select></div>${finAddBtn}</div><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold align-middle">项目名称</th><th class="p-2 text-center font-semibold align-middle">状态</th><th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款差额</th><th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th><th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th><th class="p-2 text-center font-semibold align-middle">应付差额</th><th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th></tr></thead><tbody>${rows.length ? rows.join("") : '<tr><td colspan="9" class="p-6 text-center text-slate-400">该月份暂无数据</td></tr>'}<tr class="border-t-2 border-slate-200 bg-slate-50 font-bold"><td class="p-2 text-center" colspan="2">合计</td><td class="p-2 text-center text-blue-700 align-middle">${money(sumRev)}</td><td class="p-2 text-center text-amber-700 align-middle">${money(sumPay)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumRev - sumPay)}</td><td class="p-2 text-center text-rose-700 align-middle">${money(sumCost)}</td><td class="p-2 text-center text-purple-700 align-middle">${money(sumPaid)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumCost - sumPaid)}</td><td class="p-2 text-center font-semibold align-middle ${sumPay - sumPaid >= 0 ? 'text-green-600' : 'text-red-600'}">${money(sumPay - sumPaid)}</td></tr></tbody></table></div>`, "p-4");
|
||||||
|
})() : state.finView === 'quarterly' ? (() => {
|
||||||
|
const allPfs = pfs.filter(x => x.status === state.finFilter);
|
||||||
|
const qRanges = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
|
||||||
|
const qLabels = ["Q1 (1-3月)", "Q2 (4-6月)", "Q3 (7-9月)", "Q4 (10-12月)"];
|
||||||
|
const now = new Date();
|
||||||
|
if (state.finQuarter === undefined) {
|
||||||
|
const saved = localStorage.getItem("opc-fin-quarter");
|
||||||
|
state.finQuarter = saved !== null ? parseInt(saved) : Math.floor(now.getMonth() / 3);
|
||||||
|
}
|
||||||
|
const selQ = state.finQuarter;
|
||||||
|
const qRange = qRanges[selQ];
|
||||||
|
const quarterOpts = qLabels.map((l, i) => `<option value="${i}" ${i === selQ ? 'selected' : ''}>${l}</option>`).join("");
|
||||||
|
const sumBudget = (pf, field) => {
|
||||||
|
let total = 0;
|
||||||
|
try { JSON.parse(pf.budget_data || "[]").forEach(b => {
|
||||||
|
const m = parseInt((b.month || "").substring(5)) || 0;
|
||||||
|
if (qRange.includes(m)) total += parseFloat(b[field] || 0);
|
||||||
|
}); } catch (e) {}
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
const fmt = (v) => v ? `<span class="font-medium">${money(v)}</span>` : '<span class="text-slate-300">—</span>';
|
||||||
|
const fmtDiff = (v) => { if (!v) return '<span class="text-slate-300">—</span>'; return `<span class="${v > 0 ? 'text-amber-600 font-medium' : 'text-green-600 font-medium'}">${money(Math.abs(v))}</span>`; };
|
||||||
|
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0;
|
||||||
|
const rows = [];
|
||||||
|
allPfs.forEach(pf => {
|
||||||
|
const rev = sumBudget(pf, "rev");
|
||||||
|
const payment = sumBudget(pf, "payment");
|
||||||
|
const cost = sumBudget(pf, "cost");
|
||||||
|
const paid = sumBudget(pf, "paid");
|
||||||
|
const payDiff = rev - payment;
|
||||||
|
const costDiff = cost - paid;
|
||||||
|
const cashflow = payment - paid;
|
||||||
|
sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid;
|
||||||
|
rows.push(`<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center">${esc(pf.customer_name)}</td><td class="p-2 text-center text-sm">${pf.status==='已签约'?'<span class="text-green-600">已签约</span>':pf.status==='流程中'?'<span class="text-blue-600">流程中</span>':'<span class="text-amber-600">待签约</span>'}</td><td class="p-2 text-center text-blue-700 align-middle">${fmt(rev)}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(payment)}</td><td class="p-2 text-center align-middle">${fmtDiff(payDiff)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(paid)}</td><td class="p-2 text-center align-middle">${fmtDiff(costDiff)}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`);
|
||||||
|
});
|
||||||
|
return card(`<div class="flex justify-between items-center mb-3"><div class="flex items-center gap-2">${finHeaderBase}<span class="text-xs text-slate-500">季度:</span><select onchange="state.finQuarter=parseInt(this.value);localStorage.setItem('opc-fin-quarter',this.value);renderFinance()" class="text-xs font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px">${quarterOpts}</select></div>${finAddBtn}</div><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold align-middle">项目名称</th><th class="p-2 text-center font-semibold align-middle">状态</th><th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款差额</th><th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th><th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th><th class="p-2 text-center font-semibold align-middle">应付差额</th><th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th></tr></thead><tbody>${rows.length ? rows.join("") : '<tr><td colspan="9" class="p-6 text-center text-slate-400">该季度暂无数据</td></tr>'}<tr class="border-t-2 border-slate-200 bg-slate-50 font-bold"><td class="p-2 text-center" colspan="2">合计</td><td class="p-2 text-center text-blue-700 align-middle">${money(sumRev)}</td><td class="p-2 text-center text-amber-700 align-middle">${money(sumPay)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumRev - sumPay)}</td><td class="p-2 text-center text-rose-700 align-middle">${money(sumCost)}</td><td class="p-2 text-center text-purple-700 align-middle">${money(sumPaid)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumCost - sumPaid)}</td> <td class="p-2 text-center font-semibold align-middle ${sumPay - sumPaid >= 0 ? 'text-green-600' : 'text-red-600'}">${money(sumPay - sumPaid)}</td></tr></tbody></table></div>`, "p-4");
|
||||||
})() : (() => {
|
})() : (() => {
|
||||||
const calcTotals = (pf) => {
|
const calcTotals = (pf) => {
|
||||||
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
|
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
|
||||||
@@ -215,7 +297,7 @@ function renderFinance() {
|
|||||||
allPfs.forEach(pf => { const t = calcTotals(pf); sumRev+=t.rev; sumPay+=t.payment; sumCost+=t.cost; sumPaid+=t.paid; });
|
allPfs.forEach(pf => { const t = calcTotals(pf); sumRev+=t.rev; sumPay+=t.payment; sumCost+=t.cost; sumPaid+=t.paid; });
|
||||||
const fmt = (v) => v ? `<span class="font-medium">${money(v)}</span>` : '<span class="text-slate-300">—</span>';
|
const fmt = (v) => v ? `<span class="font-medium">${money(v)}</span>` : '<span class="text-slate-300">—</span>';
|
||||||
const fmtDiff = (v) => { if (!v) return '<span class="text-slate-300">—</span>'; return `<span class="${v > 0 ? 'text-amber-600 font-medium' : 'text-green-600 font-medium'}">${money(Math.abs(v))}</span>`; };
|
const fmtDiff = (v) => { if (!v) return '<span class="text-slate-300">—</span>'; return `<span class="${v > 0 ? 'text-amber-600 font-medium' : 'text-green-600 font-medium'}">${money(Math.abs(v))}</span>`; };
|
||||||
return card(`<div class="flex items-center justify-between mb-3"><h3 class="font-bold text-slate-700">总视图 <span class="text-slate-400 font-normal">(${allPfs.length})</span></h3></div><div class="flex gap-2 mb-3">${[["已签约","已签约"],["流程中","流程中"],["待签约","待签约"]].map(([k,v]) => `<button class="btn btn-sm ${state.finFilter === k ? 'btn-primary' : 'btn-ghost'}" onclick="state.finFilter='${k}';renderFinance()">${v} (${pfs.filter(x=>x.status===k).length})</button>`).join("")}</div><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold align-middle">项目名称</th><th class="p-2 text-center font-semibold align-middle">类型</th><th class="p-2 text-center font-semibold align-middle">状态</th><th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款差额</th><th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th><th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th><th class="p-2 text-center font-semibold align-middle">应付差额</th><th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th></tr></thead><tbody>${allPfs.map(pf => { const t = calcTotals(pf); const payDiff = t.rev - t.payment; const costDiff = t.cost - t.paid; const cashflow = t.payment - t.paid; return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center">${esc(pf.customer_name)}</td><td class="p-2 text-sm text-center text-slate-500">${esc(pf.business_type)}</td><td class="p-2 text-sm text-center">${pf.status === '已签约' ? badge('已签约') : pf.status === '流程中' ? badge('流程中','blue') : badge('待签约','amber')}</td><td class="p-2 text-center text-blue-700 align-middle">${fmt(t.rev)}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(t.payment)}</td><td class="p-2 text-center align-middle">${fmtDiff(payDiff)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(t.cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(t.paid)}</td><td class="p-2 text-center align-middle">${fmtDiff(costDiff)}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`; }).join("")}<tr class="border-t-2 border-slate-200 bg-slate-50 font-bold"><td class="p-2 text-center" colspan="3">合计</td><td class="p-2 text-center text-blue-700 align-middle">${money(sumRev)}</td><td class="p-2 text-center text-amber-700 align-middle">${money(sumPay)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumRev - sumPay)}</td><td class="p-2 text-center text-rose-700 align-middle">${money(sumCost)}</td><td class="p-2 text-center text-purple-700 align-middle">${money(sumPaid)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumCost - sumPaid)}</td><td class="p-2 text-center font-semibold align-middle ${sumPay - sumPaid >= 0 ? 'text-green-600' : 'text-red-600'}">${money(sumPay - sumPaid)}</td></tr></tbody></table></div>`, "p-4");
|
return card(`<div class="flex justify-between items-center mb-3"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold align-middle">项目名称</th><th class="p-2 text-center font-semibold align-middle">状态</th><th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款差额</th><th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th><th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th><th class="p-2 text-center font-semibold align-middle">应付差额</th><th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th></tr></thead><tbody>${allPfs.map(pf => { const t = calcTotals(pf); const payDiff = t.rev - t.payment; const costDiff = t.cost - t.paid; const cashflow = t.payment - t.paid; return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center">${esc(pf.customer_name)}</td><td class="p-2 text-center text-sm">${pf.status==='已签约'?'<span class="text-green-600">已签约</span>':pf.status==='流程中'?'<span class="text-blue-600">流程中</span>':'<span class="text-amber-600">待签约</span>'}</td><td class="p-2 text-center text-blue-700 align-middle">${fmt(t.rev)}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(t.payment)}</td><td class="p-2 text-center align-middle">${fmtDiff(payDiff)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(t.cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(t.paid)}</td><td class="p-2 text-center align-middle">${fmtDiff(costDiff)}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`; }).join("")}<tr class="border-t-2 border-slate-200 bg-slate-50 font-bold"><td class="p-2 text-center" colspan="2">合计</td><td class="p-2 text-center text-blue-700 align-middle">${money(sumRev)}</td><td class="p-2 text-center text-amber-700 align-middle">${money(sumPay)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumRev - sumPay)}</td><td class="p-2 text-center text-rose-700 align-middle">${money(sumCost)}</td><td class="p-2 text-center text-purple-700 align-middle">${money(sumPaid)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumCost - sumPaid)}</td><td class="p-2 text-center font-semibold align-middle ${sumPay - sumPaid >= 0 ? 'text-green-600' : 'text-red-600'}">${money(sumPay - sumPaid)}</td></tr></tbody></table></div>`, "p-4");
|
||||||
})()}
|
})()}
|
||||||
</div>`;
|
</div>`;
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
@@ -230,6 +312,7 @@ window.openFinanceModal = () => {
|
|||||||
const pfIdInput = form.querySelector('[name="pf_id"]');
|
const pfIdInput = form.querySelector('[name="pf_id"]');
|
||||||
if (!pfIdInput || !pfIdInput.value) {
|
if (!pfIdInput || !pfIdInput.value) {
|
||||||
initBudgetTable(null);
|
initBudgetTable(null);
|
||||||
|
initTaskTable(null);
|
||||||
document.querySelector("#financeDeleteBtn").classList.add("hidden");
|
document.querySelector("#financeDeleteBtn").classList.add("hidden");
|
||||||
}
|
}
|
||||||
modal.classList.remove("hidden");
|
modal.classList.remove("hidden");
|
||||||
@@ -284,6 +367,101 @@ window.initBudgetTable = (budgetData) => {
|
|||||||
setTimeout(() => updateBudgetSummary(), 50);
|
setTimeout(() => updateBudgetSummary(), 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------- 任务管理 tab ----------
|
||||||
|
const TASK_TYPES = ["科普视频", "科普专访", "科普文章", "问卷调研", "病例征集"];
|
||||||
|
|
||||||
|
function taskMonthOptions(selected) {
|
||||||
|
const now = new Date();
|
||||||
|
const opts = [];
|
||||||
|
for (let i = -2; i <= 12; i++) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
|
||||||
|
const v = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
opts.push(`<option value="${v}" ${v === selected ? 'selected' : ''}>${v}</option>`);
|
||||||
|
}
|
||||||
|
return opts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addTaskRow = (taskMonth = '', taskType = '', taskCount = '', executedCount = '', unitPrice = '') => {
|
||||||
|
const tbody = document.querySelector("#taskTbody");
|
||||||
|
if (!tbody) return;
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
const defaultMonth = (() => { const n = new Date(); return n.getFullYear() + "-" + String(n.getMonth()+1).padStart(2,"0"); })();
|
||||||
|
const isPreset = TASK_TYPES.includes(taskType);
|
||||||
|
const typeCell = isPreset || !taskType
|
||||||
|
? `<select name="task_type[]" class="form-ctrl form-ctrl-sm w-full" onchange="onTaskTypeChange(this)"><option value="">选择</option>${TASK_TYPES.map(t => `<option ${t === taskType ? 'selected' : ''}>${t}</option>`).join("")}<option value="__custom__" ${!isPreset && taskType ? 'selected' : ''}>自定义...</option></select>`
|
||||||
|
: `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" value="${esc(taskType)}" placeholder="输入自定义类型"><button type="button" class="btn btn-ghost btn-sm text-slate-400 p-0 w-5 h-5 ml-1" onclick="revertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
|
||||||
|
row.innerHTML = `<td><select name="task_month[]" class="form-ctrl form-ctrl-sm w-full">${taskMonthOptions(taskMonth || defaultMonth)}</select></td>
|
||||||
|
<td class="flex items-center">${typeCell}</td>
|
||||||
|
<td><input name="task_count[]" type="number" step="1" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:100px" placeholder="0" value="${taskCount}" oninput="updateTaskDiff(this)"></td>
|
||||||
|
<td><input name="task_executed[]" type="number" step="1" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:100px" placeholder="0" value="${executedCount}" oninput="updateTaskDiff(this)"></td>
|
||||||
|
<td><input name="task_diff[]" type="number" class="form-ctrl form-ctrl-sm text-right" style="width:80px" placeholder="—" disabled></td>
|
||||||
|
<td><input name="task_unit_price[]" type="number" step="0.01" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:90px" placeholder="0" value="${unitPrice}" oninput="updateTaskDiff(this)"></td>
|
||||||
|
<td><input name="task_exec_amount[]" type="number" class="form-ctrl form-ctrl-sm text-right" style="width:140px" placeholder="—" disabled></td>
|
||||||
|
<td><input name="task_unexec_amount[]" type="number" class="form-ctrl form-ctrl-sm text-right" style="width:140px" placeholder="—" disabled></td>
|
||||||
|
<td><button type="button" class="btn btn-ghost btn-sm text-red-500 p-0 w-6 h-6" onclick="this.closest('tr').remove()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
updateRowCalc(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.updateTaskDiff = (el) => {
|
||||||
|
const row = el.closest('tr');
|
||||||
|
if (!row) return;
|
||||||
|
updateRowCalc(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateRowCalc(row) {
|
||||||
|
const countInput = row.querySelector('[name="task_count[]"]');
|
||||||
|
const execInput = row.querySelector('[name="task_executed[]"]');
|
||||||
|
const priceInput = row.querySelector('[name="task_unit_price[]"]');
|
||||||
|
const diffInput = row.querySelector('[name="task_diff[]"]');
|
||||||
|
const execAmtInput = row.querySelector('[name="task_exec_amount[]"]');
|
||||||
|
const unexecAmtInput = row.querySelector('[name="task_unexec_amount[]"]');
|
||||||
|
const c = parseFloat(countInput.value) || 0;
|
||||||
|
const e = parseFloat(execInput.value) || 0;
|
||||||
|
const p = parseFloat(priceInput.value) || 0;
|
||||||
|
const diff = c - e;
|
||||||
|
// 差额
|
||||||
|
diffInput.value = (!c && !e) ? '' : diff;
|
||||||
|
// 执行金额 = 单价 * 已执行
|
||||||
|
const execAmt = p * e;
|
||||||
|
execAmtInput.value = execAmt ? execAmt.toFixed(2) : '';
|
||||||
|
// 未执行金额 = 单价 * 差额
|
||||||
|
const unexecAmt = p * diff;
|
||||||
|
unexecAmtInput.value = unexecAmt ? unexecAmt.toFixed(2) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.toggleBtChip = (chip) => {
|
||||||
|
const cb = chip.querySelector('input');
|
||||||
|
cb.checked = !cb.checked;
|
||||||
|
chip.classList.toggle('bg-blue-50', cb.checked);
|
||||||
|
chip.classList.toggle('border-blue-400', cb.checked);
|
||||||
|
chip.classList.toggle('text-blue-600', cb.checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.initTaskTable = (taskData) => {
|
||||||
|
const tbody = document.querySelector("#taskTbody");
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
const rows = taskData || [];
|
||||||
|
rows.forEach(r => addTaskRow(r.task_month || '', r.task_type || '', r.task_count || '', r.task_executed || '', r.unit_price || ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.onTaskTypeChange = (sel) => {
|
||||||
|
if (sel.value !== '__custom__') return;
|
||||||
|
const td = sel.parentElement;
|
||||||
|
const oldVal = sel.value;
|
||||||
|
td.innerHTML = `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" placeholder="输入自定义类型" autofocus><button type="button" class="btn btn-ghost btn-sm text-slate-400 p-0 w-5 h-5 ml-1" onclick="revertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
td.querySelector('input').focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.revertTaskType = (btn) => {
|
||||||
|
const td = btn.parentElement;
|
||||||
|
td.innerHTML = `<select name="task_type[]" class="form-ctrl form-ctrl-sm w-full" onchange="onTaskTypeChange(this)"><option value="">选择</option>${TASK_TYPES.map(t => `<option>${t}</option>`).join("")}<option value="__custom__">自定义...</option></select>`;
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
};
|
||||||
|
|
||||||
window.closeFinanceModal = () => {
|
window.closeFinanceModal = () => {
|
||||||
const modal = document.querySelector("#financeModal");
|
const modal = document.querySelector("#financeModal");
|
||||||
modal.classList.add("hidden");
|
modal.classList.add("hidden");
|
||||||
@@ -320,6 +498,63 @@ window.switchFinanceTab = (tab) => {
|
|||||||
document.querySelectorAll(".finance-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === 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("#financeTabInfo").classList.toggle("hidden", tab !== "info");
|
||||||
document.querySelector("#financeTabBudget").classList.toggle("hidden", tab !== "budget");
|
document.querySelector("#financeTabBudget").classList.toggle("hidden", tab !== "budget");
|
||||||
|
document.querySelector("#financeTabExec").classList.toggle("hidden", tab !== "exec");
|
||||||
|
document.querySelector("#financeTabTasks").classList.toggle("hidden", tab !== "tasks");
|
||||||
|
document.querySelector("#financeTabActivity").classList.toggle("hidden", tab !== "activity");
|
||||||
|
document.querySelector(".finance-form-actions").classList.toggle("hidden", tab === "activity");
|
||||||
|
if (tab === "activity") initFinSquire();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 活动与跟进 ----------
|
||||||
|
|
||||||
|
async function loadFinFollowups(pfId) {
|
||||||
|
const list = document.querySelector("#finActivityList");
|
||||||
|
if (!list || !pfId) return;
|
||||||
|
try {
|
||||||
|
const fups = await api(`/api/followups/project_finance/${pfId}`);
|
||||||
|
list.innerHTML = fups.length
|
||||||
|
? fups.map(f => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${esc(f.follower)} · ${esc(f.follow_up_method)}</span><span>${esc(f.followed_at)}</span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html="${encodeURIComponent(f.content || '')}"></div>${f.next_action ? `<p class="mt-1 leading-5 text-blue-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" type="button" onclick="deleteFinFollowup(event, ${f.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")
|
||||||
|
: '<p class="text-sm text-slate-400 py-4 text-center">暂无跟进记录</p>';
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
list.querySelectorAll(".rich-content").forEach(el => {
|
||||||
|
const html = el.dataset.html;
|
||||||
|
if (html) el.innerHTML = decodeURIComponent(html);
|
||||||
|
});
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFinSquire() {
|
||||||
|
const ed = document.querySelector("#squire_finance");
|
||||||
|
if (!ed || !window.Squire) return;
|
||||||
|
if (window.squireInstances["squire_finance"]) { window.squireInstances["squire_finance"].destroy(); }
|
||||||
|
const sq = new Squire(ed, { blockTag: "P" });
|
||||||
|
window.squireInstances["squire_finance"] = sq;
|
||||||
|
ed.addEventListener("focus", () => ed.classList.add("focused"));
|
||||||
|
ed.addEventListener("blur", () => { if (!ed.textContent.trim()) ed.classList.remove("focused"); });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.submitFinComment = async () => {
|
||||||
|
const pfId = document.querySelector("#pf-id-input").value;
|
||||||
|
if (!pfId) return;
|
||||||
|
const sq = window.squireInstances["squire_finance"];
|
||||||
|
const content = sq ? sq.getHTML().trim() : "";
|
||||||
|
if (!content || content === "<div><br></div>" || content === "<p><br></p>") return;
|
||||||
|
const btn = document.querySelector("#financeTabActivity .comment-submit");
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = "发送中…";
|
||||||
|
await api(`/api/followups/project_finance/${pfId}`, { method: "POST", body: JSON.stringify({ data: { content } }) });
|
||||||
|
sq.setHTML("");
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "评论";
|
||||||
|
await loadFinFollowups(pfId);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.deleteFinFollowup = async (event, followupId) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!confirm("确认删除这条评论?")) return;
|
||||||
|
await api(`/api/followups/${followupId}`, { method: "DELETE" });
|
||||||
|
const pfId = document.querySelector("#pf-id-input").value;
|
||||||
|
if (pfId) await loadFinFollowups(pfId);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.openPfEditModal = (pfId) => {
|
window.openPfEditModal = (pfId) => {
|
||||||
@@ -332,8 +567,20 @@ window.openPfEditModal = (pfId) => {
|
|||||||
form.querySelector('[name="project_id"]').value = pf.project_id || "";
|
form.querySelector('[name="project_id"]').value = pf.project_id || "";
|
||||||
const deptDisplay = form.querySelector('.bg-slate-50 [disabled]');
|
const deptDisplay = form.querySelector('.bg-slate-50 [disabled]');
|
||||||
if (deptDisplay) deptDisplay.value = pf.project_id || "";
|
if (deptDisplay) deptDisplay.value = pf.project_id || "";
|
||||||
form.querySelector('[name="business_type"]').value = pf.business_type || "";
|
// 回填业务类型多选
|
||||||
|
const btValues = (pf.business_type || "").split(/[,,]/).map(s => s.trim()).filter(Boolean);
|
||||||
|
form.querySelectorAll('[name="business_type[]"]').forEach(cb => {
|
||||||
|
cb.checked = btValues.includes(cb.value);
|
||||||
|
const chip = cb.closest('.bt-chip');
|
||||||
|
if (chip) {
|
||||||
|
chip.classList.toggle('bg-blue-50', cb.checked);
|
||||||
|
chip.classList.toggle('border-blue-400', cb.checked);
|
||||||
|
chip.classList.toggle('text-blue-600', cb.checked);
|
||||||
|
}
|
||||||
|
});
|
||||||
form.querySelector('[name="customer_name"]').value = pf.customer_name || "";
|
form.querySelector('[name="customer_name"]').value = pf.customer_name || "";
|
||||||
|
const setVal = (name, val) => { const el = form.querySelector(`[name="${name}"]`); if (el) el.value = val || ""; };
|
||||||
|
setVal("project_code", pf.project_code);
|
||||||
form.querySelector('[name="sign_amount"]').value = pf.sign_amount || "";
|
form.querySelector('[name="sign_amount"]').value = pf.sign_amount || "";
|
||||||
const signMonthValue = pf.sign_month || "";
|
const signMonthValue = pf.sign_month || "";
|
||||||
const signMonthEl = form.querySelector('[name="sign_month"]');
|
const signMonthEl = form.querySelector('[name="sign_month"]');
|
||||||
@@ -344,10 +591,23 @@ window.openPfEditModal = (pfId) => {
|
|||||||
form.querySelector('[name="status"]').value = pf.status || "待签约";
|
form.querySelector('[name="status"]').value = pf.status || "待签约";
|
||||||
form.querySelector('[name="sales_person"]').value = pf.sales_person || "";
|
form.querySelector('[name="sales_person"]').value = pf.sales_person || "";
|
||||||
form.querySelector('[name="owner"]').value = pf.owner || "";
|
form.querySelector('[name="owner"]').value = pf.owner || "";
|
||||||
|
setVal("start_date", pf.start_date);
|
||||||
|
setVal("end_date", pf.end_date);
|
||||||
|
setVal("task_type", pf.task_type);
|
||||||
|
setVal("task_count", pf.task_count);
|
||||||
|
setVal("service_fee_standard", pf.service_fee_standard || 5);
|
||||||
|
setVal("project_manager", pf.project_manager);
|
||||||
|
setVal("contact_name", pf.contact_name);
|
||||||
|
setVal("contact_phone", pf.contact_phone);
|
||||||
|
setVal("other_info", pf.other_info);
|
||||||
let budgetData = [];
|
let budgetData = [];
|
||||||
try { budgetData = JSON.parse(pf.budget_data || "[]"); } catch (e) { budgetData = []; }
|
try { budgetData = JSON.parse(pf.budget_data || "[]"); } catch (e) { budgetData = []; }
|
||||||
initBudgetTable(budgetData.length ? budgetData : null);
|
initBudgetTable(budgetData.length ? budgetData : null);
|
||||||
|
let taskData = [];
|
||||||
|
try { taskData = JSON.parse(pf.task_data || "[]"); } catch (e) { taskData = []; }
|
||||||
|
initTaskTable(taskData.length ? taskData : null);
|
||||||
setTimeout(() => updateBudgetSummary(), 100);
|
setTimeout(() => updateBudgetSummary(), 100);
|
||||||
|
loadFinFollowups(pf.id);
|
||||||
openFinanceModal();
|
openFinanceModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -355,6 +615,9 @@ window.createFinance = async (event) => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.currentTarget;
|
const form = event.currentTarget;
|
||||||
const data = Object.fromEntries(new FormData(form).entries());
|
const data = Object.fromEntries(new FormData(form).entries());
|
||||||
|
// 业务类型多选:合并为逗号分隔
|
||||||
|
const btChecked = form.querySelectorAll('[name="business_type[]"]:checked');
|
||||||
|
data.business_type = Array.from(btChecked).map(cb => cb.value).join(",");
|
||||||
data.tenant = state.tenant;
|
data.tenant = state.tenant;
|
||||||
// 必填校验
|
// 必填校验
|
||||||
if (!data.customer_name || !data.customer_name.trim()) { toast("项目名称必填", "error"); return; }
|
if (!data.customer_name || !data.customer_name.trim()) { toast("项目名称必填", "error"); return; }
|
||||||
@@ -392,6 +655,29 @@ window.createFinance = async (event) => {
|
|||||||
for (const r of budgetRows) { totalPayment += r.payment; totalCost += r.cost; }
|
for (const r of budgetRows) { totalPayment += r.payment; totalCost += r.cost; }
|
||||||
data.total_payment = totalPayment;
|
data.total_payment = totalPayment;
|
||||||
data.total_cost = totalCost;
|
data.total_cost = totalCost;
|
||||||
|
// 收集任务管理数据
|
||||||
|
const taskTypeInputs = form.querySelectorAll('[name="task_type[]"]');
|
||||||
|
const taskCountInputs = form.querySelectorAll('[name="task_count[]"]');
|
||||||
|
const taskExecInputs = form.querySelectorAll('[name="task_executed[]"]');
|
||||||
|
const taskRows = [];
|
||||||
|
for (let i = 0; i < taskTypeInputs.length; i++) {
|
||||||
|
const tt = taskTypeInputs[i].value.trim();
|
||||||
|
if (!tt) continue;
|
||||||
|
taskRows.push({
|
||||||
|
task_month: form.querySelectorAll('[name="task_month[]"]')[i].value || '',
|
||||||
|
task_type: tt,
|
||||||
|
task_count: parseFloat(taskCountInputs[i].value) || 0,
|
||||||
|
task_executed: parseFloat(taskExecInputs[i].value) || 0,
|
||||||
|
unit_price: parseFloat(form.querySelectorAll('[name="task_unit_price[]"]')[i].value) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
data.task_data = JSON.stringify(taskRows);
|
||||||
|
// 清除数组命名的字段(FormData 会收集 task_type[] 等),避免后端写入不存在的列
|
||||||
|
delete data.task_type;
|
||||||
|
delete data.task_count;
|
||||||
|
for (const key of Object.keys(data)) {
|
||||||
|
if (key.endsWith('[]')) delete data[key];
|
||||||
|
}
|
||||||
const pfId = data.pf_id;
|
const pfId = data.pf_id;
|
||||||
delete data.pf_id;
|
delete data.pf_id;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,28 +8,55 @@ function renderHome() {
|
|||||||
["年度累计", moneyInt(m.signed_annual || m.signed_amount)],
|
["年度累计", moneyInt(m.signed_annual || m.signed_amount)],
|
||||||
["季度累计", moneyInt(m.signed_q2 || 0)],
|
["季度累计", moneyInt(m.signed_q2 || 0)],
|
||||||
["本月新增", moneyInt(m.signed_month || 0)],
|
["本月新增", moneyInt(m.signed_month || 0)],
|
||||||
|
["上季度累计", moneyInt(m.signed_prev_q || 0)],
|
||||||
|
["上月累计", moneyInt(m.signed_prev_month || 0)],
|
||||||
];
|
];
|
||||||
const rows2 = [
|
const rows2 = [
|
||||||
["年度累计", moneyInt(m.revenue_annual)],
|
["年度累计", moneyInt(m.revenue_annual)],
|
||||||
["季度累计", moneyInt(m.revenue_q2)],
|
["季度累计", moneyInt(m.revenue_q2)],
|
||||||
["本月新增", moneyInt(m.monthly_revenue)],
|
["本月新增", moneyInt(m.monthly_revenue)],
|
||||||
|
["上季度累计", moneyInt(m.revenue_prev_q || 0)],
|
||||||
|
["上月累计", moneyInt(m.revenue_prev_month || 0)],
|
||||||
];
|
];
|
||||||
const rows3 = [
|
const rows3 = [
|
||||||
["年度累计", moneyInt(m.gross_annual)],
|
["年度累计", moneyInt(m.gross_annual)],
|
||||||
["季度累计", moneyInt(m.gross_q2)],
|
["季度累计", moneyInt(m.gross_q2)],
|
||||||
["本月新增", moneyInt(m.monthly_net_profit)],
|
["本月新增", moneyInt(m.monthly_net_profit)],
|
||||||
|
["上季度累计", moneyInt(m.gross_prev_q || 0)],
|
||||||
|
["上月累计", moneyInt(m.gross_prev_month || 0)],
|
||||||
];
|
];
|
||||||
const rows4 = [
|
const rows4 = [
|
||||||
["年度累计", moneyInt(m.payment_annual || 0)],
|
["年度累计", moneyInt(m.payment_annual || 0)],
|
||||||
["季度累计", moneyInt(m.payment_q2 || 0)],
|
["季度累计", moneyInt(m.payment_q2 || 0)],
|
||||||
["本月新增", moneyInt(m.payment_month || 0)],
|
["本月新增", moneyInt(m.payment_month || 0)],
|
||||||
|
["上季度累计", moneyInt(m.payment_prev_q || 0)],
|
||||||
|
["上月累计", moneyInt(m.payment_prev_month || 0)],
|
||||||
];
|
];
|
||||||
const rows5 = [
|
const rows5 = [
|
||||||
["年度累计", moneyInt(m.cost_annual || 0)],
|
["年度累计", moneyInt(m.cost_annual || 0)],
|
||||||
["季度累计", moneyInt(m.cost_q2 || 0)],
|
["季度累计", moneyInt(m.cost_q2 || 0)],
|
||||||
["本月新增", moneyInt(m.cost_month || 0)],
|
["本月新增", moneyInt(m.cost_month || 0)],
|
||||||
|
["上季度累计", moneyInt(m.cost_prev_q || 0)],
|
||||||
|
["上月累计", moneyInt(m.cost_prev_month || 0)],
|
||||||
];
|
];
|
||||||
const tblCard = (title, rows) => card(`<h3 class="text-sm font-bold text-slate-700 mb-3">${title}</h3><table class="w-full text-sm"><tbody>${rows.map(([label, value]) => `<tr class="border-b border-slate-100 last:border-0"><td class="py-2 pr-4 text-slate-500">${label}</td><td class="py-2 text-right font-semibold text-slate-800">${value}</td></tr>`).join("")}</tbody></table>`, "p-4");
|
let _cardId = 0;
|
||||||
|
const tblCard = (title, rows) => {
|
||||||
|
const id = 'finCard' + (++_cardId);
|
||||||
|
const visible = rows.slice(0, 3);
|
||||||
|
const extra = rows.slice(3);
|
||||||
|
const rowHtml = (r) => r.map(([label, value]) => `<tr class="border-b border-slate-100 last:border-0"><td class="py-2 pr-4 text-slate-500">${label}</td><td class="py-2 text-right font-semibold text-slate-800">${value}</td></tr>`).join("");
|
||||||
|
return card(`<h3 class="text-sm font-bold text-slate-700 mb-3">${title}</h3><table class="w-full text-sm"><tbody>${rowHtml(visible)}</tbody></table><table class="w-full text-sm hidden" id="${id}"><tbody>${rowHtml(extra)}</tbody></table><button class="w-full text-center py-1 text-xs text-slate-400 hover:text-slate-600" onclick="toggleFinCard('${id}')" id="${id}_btn"><i data-lucide="chevron-down" style="width:14px;height:14px;display:inline"></i></button>`, "p-4");
|
||||||
|
};
|
||||||
|
window.toggleFinCard = (id) => {
|
||||||
|
const t = document.getElementById(id);
|
||||||
|
if (!t) return;
|
||||||
|
t.classList.toggle("hidden");
|
||||||
|
const btn = document.getElementById(id + "_btn");
|
||||||
|
btn.innerHTML = t.classList.contains("hidden")
|
||||||
|
? '<i data-lucide="chevron-down" style="width:14px;height:14px;display:inline"></i>'
|
||||||
|
: '<i data-lucide="chevron-up" style="width:14px;height:14px;display:inline"></i>';
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
};
|
||||||
document.querySelector("#home").innerHTML = `
|
document.querySelector("#home").innerHTML = `
|
||||||
<div class="grid gap-5">
|
<div class="grid gap-5">
|
||||||
${state.tenant === "总工作台" ? "" : `<div class="grid grid-cols-4 gap-3">
|
${state.tenant === "总工作台" ? "" : `<div class="grid grid-cols-4 gap-3">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const state = {
|
|||||||
selectedProject: null,
|
selectedProject: null,
|
||||||
taskQuery: "",
|
taskQuery: "",
|
||||||
taskView: localStorage.getItem("opc-task-view") || "detail",
|
taskView: localStorage.getItem("opc-task-view") || "detail",
|
||||||
finView: localStorage.getItem("opc-fin-view") || "rev",
|
finView: localStorage.getItem("opc-fin-view") || "overview",
|
||||||
proposalTab: "standard",
|
proposalTab: "standard",
|
||||||
chart: null,
|
chart: null,
|
||||||
chart2: null,
|
chart2: null,
|
||||||
|
|||||||
@@ -94,6 +94,33 @@ body {
|
|||||||
border-bottom-color: #1d4ed8;
|
border-bottom-color: #1d4ed8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 详情弹窗固定高度,tab 切换不抖动 */
|
||||||
|
#financeModal > div {
|
||||||
|
height: 650px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
#financeModal .finance-tabs { flex-shrink: 0; }
|
||||||
|
#financeModal form {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#financeModal .finance-tab-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
#financeModal .finance-form-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px 32px 8px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
/* 项目统一卡片布局 */
|
/* 项目统一卡片布局 */
|
||||||
.project-board {
|
.project-board {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|||||||
Reference in New Issue
Block a user