Compare commits

...

28 Commits

Author SHA1 Message Date
mac
ac6eacea82 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-02 17:55:40 +08:00
mac
34786ba9e5 产品迭代表格化 + 财务总视图/月度视图 + 总工作台
产品迭代:
- 卡片改表格(10列),5个日期内联编辑,后端日期校验
- 表头排序,新增未开始状态,详情页耗时统计
- 删除 owner/platform/feature_list 字段

财务:
- 新增总视图和月度视图,去除确收/毛利和回款/应付视图
- 月度流水加已付列,费用改应付
- 月份选择器,表格居中对齐
- 去除流程项目/流程金额卡片

总工作台:
- 聚合所有工作台首页数据
- 只显示首页tab,隐藏4个模块卡片
2026-07-02 17:55:40 +08:00
mac
83c2a5ca94 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-02 14:50:45 +08:00
mac
fbff2e5f24 产品迭代:表格排序 + 详情页优先级状态对齐
- 表头点击排序(正序/倒序切换),优先级按 P0-P3 逻辑序
- 排序图标与表头文字同行显示
- 详情页优先级/状态合并行改用标准 drawer-field 结构对齐
2026-07-02 14:50:45 +08:00
mac
fbd2290d29 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-02 14:31:06 +08:00
mac
0eb9d69f1e 产品迭代模块:卡片改表格 + 日期内联编辑 + 后端日期校验
- 卡片列表改为表格列表(10列),参考用户运营中心产品台账
- 数据库新增 priority + 5 个日期字段(start/plan/dev_done/test/launch)
- 删除 owner/platform/feature_list 字段(migrate_drop_product_fields)
- 日期内联编辑:5个日期列直接渲染 date input
- 后端日期校验:4个时间不能早于启动时间;启动时间必填
- 详情页新增耗时统计区块(总/产品/研发/测试耗时)
- 优先级和状态合并同一行
- 新增'未开始'状态
- 表格垂直居中对齐
- renderProducts 后重新初始化 lucide 图标
2026-07-02 14:31:06 +08:00
mac
97fcf88c61 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-06-29 17:02:55 +08:00
mac
003b6f3bdb fix(deploy): cleanup 按修改时间排序,防字母序误删新 release
原 find + sort(字母序)导致 a 开头的 commit sha 被误判为最旧版本删除。
改为 ls -dt(按修改时间倒序),tail -n +6 保留最近 5 个。
2026-06-29 17:02:55 +08:00
mac
a01afef599 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-06-29 16:52:37 +08:00
mac
9226233de5 chore: 重新触发部署(sys.path 修复已就绪) 2026-06-29 16:52:37 +08:00
mac
28fa244fe5 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-06-29 16:45:21 +08:00
mac
96948a37de fix: migrations 模块在 gunicorn --preload 下无法导入
ModuleNotFoundError: No module named 'migrations'
根因:gunicorn 的 sys.path 不包含 backend/ 目录
修复:flask_app.py 顶层添加 sys.path.insert 确保 backend 在搜索路径中
2026-06-29 16:45:16 +08:00
mac
ad3885e0be Merge branch 'dev'
Some checks failed
Deploy / deploy (push) Failing after 23s
2026-06-29 16:02:10 +08:00
mac
d47fde60a4 chore: 触发重新部署 2026-06-29 16:02:10 +08:00
mac
0fb7ee2992 Merge branch 'dev'
Some checks failed
Deploy / deploy (push) Failing after 14m52s
2026-06-26 12:21:02 +08:00
mac
2bb99feda4 工作台重命名:无界·无界 → 学会·无界
- ALL_TENANTS / session / seed / migrations 全部同步
- 新增 migrate_rename_tenant() 数据迁移,启动自动 UPDATE 所有表
- migrations/ 模式重构(参考 SalesManager)
2026-06-26 12:21:02 +08:00
mac
f6792cad39 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 10s
2026-06-24 12:42:56 +08:00
mac
33f47acc55 左侧菜单改版:工作台下拉 + 5个图标导航
- 工作台改为下拉菜单(layout-grid + chevron-down 图标)
- 顶部 tabs 移到左侧 sidebar,5 个图标导航(首页/财务/台账/方案/产品)
- 头像与工作台间、工作台与导航间各加分隔线
- 经营管理 tab 短名改为'财务'
- 移除 .tabs 样式,新增 .sidebar-tab 样式
2026-06-24 12:42:56 +08:00
mac
bed6e9192a Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-06-23 23:12:03 +08:00
mac
2c7e6b7d29 fix: 经营管理卡片图标修复 + 金额统一取整
- 已签项目图标 file-sign → file-check-2(修复不显示)
- 11 个卡片金额从 money() 改为 moneyInt()(Math.round 取整)
2026-06-23 23:12:03 +08:00
mac
aaa213a765 fix(deploy): 修复 data 目录不存在导致 ln 软链失败
All checks were successful
Deploy / deploy (push) Successful in 10s
rsync 排除了 data/uploads 但没保留 data/ 空目录,
导致 ln -sfn shared/uploads data/uploads 时父目录不存在而失败。
在 ln 之前加 mkdir -p 确保目录存在。
2026-06-23 23:07:25 +08:00
mac
636b3fc82b fix(deploy): 修复 data 目录不存在导致 ln 软链失败
rsync 排除了 data/uploads 但没保留 data/ 空目录,
导致 ln -sfn shared/uploads data/uploads 时父目录不存在而失败。
在 ln 之前加 mkdir -p 确保目录存在。
2026-06-23 23:07:18 +08:00
Deploy Test
207629a9bb test: trigger workflow debug
Some checks failed
Deploy / deploy (push) Failing after 0s
2026-06-23 23:03:22 +08:00
mac
361359ee32 统计卡片统一为 metric-card 样式 + 增加图标
Some checks failed
Deploy / deploy (push) Failing after 1s
- 经营管理/重点工作台账卡片改用 .metric-card 类(与首页一致)
- 卡片增加 lucide 图标(签约/金额/任务/状态等)
- 布局:左对齐、text-2xl、图标+标签
2026-06-23 22:49:55 +08:00
mac
25da1453be 新增自动化部署:Gitea Actions + systemd + gunicorn
Some checks failed
Deploy / deploy (push) Failing after 1s
- .gitea/workflows/deploy.yml:push main 自动触发部署
- requirements.txt:Python 依赖清单
- deploy/opc-manager.service:systemd 服务(gunicorn --preload -w 4)
- deploy/README.md:完整部署指南
- deploy/服务器配置任务提示词.md:给服务器管理 Agent 的操作提示词
- health 接口简化返回 {ok, service}
2026-06-23 19:33:16 +08:00
mac
39f2b679a1 首页:新增回款/费用卡片 + 统计口径对齐 + UI 优化
- 新增回款金额、费用金额 2 个卡片(5 列布局)
- 卡片标题统一为 年度累计/季度累计/本月新增
- 季度计算改为动态本季度(不再写死 Q2)
- 卡片数字统一取整(moneyInt)
- 财务趋势图只统计已签约项目(与卡片口径对齐)
- net_profit 字段重命名为 gross(消除命名误导)
- 近期动态删除图标改为 trash-2(与附件删除一致)
2026-06-23 17:17:36 +08:00
mac
5f9a92b24d 经营管理视图切换按钮移到卡片外,与重点工作台账布局对齐 2026-06-23 16:20:18 +08:00
mac
b6dd913275 登录页底部改为 Powered by yxcowork.vip,移除默认账号显示 2026-06-23 16:03:11 +08:00
22 changed files with 1448 additions and 265 deletions

View File

@@ -0,0 +1,91 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: prod-deploy
env:
DEPLOY_BASE: /opt/opc-manager
REPO_URL: https://qiukai:${{ secrets.DEPLOY_TOKEN }}@git.qiukai.me/qiukai/opc-manager.git
SERVICE_NAME: opc-manager
steps:
- name: Clone and deploy
run: |
set -e
RELEASE_ID="${{ github.sha }}"
RELEASE_DIR="${DEPLOY_BASE}/releases/${RELEASE_ID}"
CLONE_DIR="/tmp/opc-deploy-${RELEASE_ID}"
echo "=== 1. Clone repository ==="
rm -rf "${CLONE_DIR}"
git clone --depth 1 --branch main "${REPO_URL}" "${CLONE_DIR}"
echo "=== 2. Prepare release directory ==="
rm -rf "${RELEASE_DIR}"
mkdir -p "${RELEASE_DIR}"
# Copy repo content to release dir (exclude .git, .env, venv, data)
rsync -a --exclude='.git' \
--exclude='.env' \
--exclude='.env.local' \
--exclude='.venv' \
--exclude='data/uploads' \
--exclude='data/opc.sqlite' \
--exclude='__pycache__' \
--exclude='.gitea' \
"${CLONE_DIR}/" "${RELEASE_DIR}/"
echo "=== 3. Link shared resources ==="
mkdir -p "${RELEASE_DIR}/data"
# .env from shared dir (not in git)
ln -sfn "${DEPLOY_BASE}/shared/.env" "${RELEASE_DIR}/.env"
# uploads directory from shared (persist across releases)
mkdir -p "${DEPLOY_BASE}/shared/uploads"
ln -sfn "${DEPLOY_BASE}/shared/uploads" "${RELEASE_DIR}/data/uploads"
echo "=== 4. Setup Python venv ==="
cd "${RELEASE_DIR}"
python3 -m venv .venv
. .venv/bin/activate
pip install --no-cache-dir -r requirements.txt
echo "=== 5. Restart service ==="
# Update WorkingDirectory in service via symlink approach
# The systemd service points to /opt/opc-manager/current
ln -sfn "${RELEASE_DIR}" "${DEPLOY_BASE}/current"
systemctl restart "${SERVICE_NAME}"
sleep 3
echo "=== 6. Health check ==="
for i in 1 2 3 4 5; do
if curl -fsS http://127.0.0.1:5177/api/health >/dev/null 2>&1; then
echo "Health check passed"
break
fi
echo "Attempt $i: waiting for service..."
sleep 2
done
# Final verify
if ! curl -fsS http://127.0.0.1:5177/api/health >/dev/null 2>&1; then
echo "ERROR: Health check failed after 5 attempts"
echo "Rolling back to previous release..."
PREV=$(ls -t "${DEPLOY_BASE}/releases" | sed -n '2p')
if [ -n "${PREV}" ]; then
ln -sfn "${DEPLOY_BASE}/releases/${PREV}" "${DEPLOY_BASE}/current"
systemctl restart "${SERVICE_NAME}"
echo "Rolled back to ${PREV}"
fi
exit 1
fi
echo "=== 7. Cleanup old releases ==="
ls -dt "${DEPLOY_BASE}"/releases/*/ | tail -n +6 | xargs -r rm -rf
echo "=== 8. Cleanup temp ==="
rm -rf "${CLONE_DIR}"
echo "=== Deploy complete: ${RELEASE_ID} ==="

View File

@@ -53,3 +53,4 @@ curl http://127.0.0.1:5177/api/health
- 产品:慰心斋产品路线图中的 5 个产品版本 - 产品:慰心斋产品路线图中的 5 个产品版本
- 财务:首版财务样例和原财务 manager 合并方向 - 财务:首版财务样例和原财务 manager 合并方向
# test trigger

View File

@@ -1,12 +1,18 @@
from datetime import date, datetime from datetime import date, datetime
from pathlib import Path from pathlib import Path
import os import os
import sys
import json import json
import shutil import shutil
import sqlite3 # 保留用于数据迁移 import sqlite3 # 保留用于数据迁移
import logging import logging
import mysql.connector 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)
from flask import Flask, jsonify, render_template, request, send_file, session, redirect from flask import Flask, jsonify, render_template, request, send_file, session, redirect
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
@@ -61,7 +67,7 @@ def admin_required(f):
return decorated return decorated
ALL_TENANTS = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "无界·无界"] ALL_TENANTS = ["总工作台", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学会·无界"]
@app.route("/login") @app.route("/login")
def login_page(): def login_page():
@@ -84,7 +90,7 @@ def auth_login():
session["role"] = user["role"] session["role"] = user["role"]
# 管理员可看所有工作台OPC负责人看分配的工作台 # 管理员可看所有工作台OPC负责人看分配的工作台
if user["role"] == "admin": if user["role"] == "admin":
session["tenants"] = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "无界·无界"] session["tenants"] = ["总工作台", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学会·无界"]
else: else:
ut = rows(conn, "SELECT tenant FROM user_tenants WHERE user_id=?", (user["id"],)) ut = rows(conn, "SELECT tenant FROM user_tenants WHERE user_id=?", (user["id"],))
session["tenants"] = [x["tenant"] for x in ut] session["tenants"] = [x["tenant"] for x in ut]
@@ -107,10 +113,14 @@ def auth_logout():
def auth_me(): def auth_me():
if "user_id" not in session: if "user_id" not in session:
return jsonify({"logged_in": False}) return jsonify({"logged_in": False})
tenants = session.get("tenants", [])
# 确保总工作台始终在列表最前
if "总工作台" not in tenants:
tenants = ["总工作台"] + tenants
return jsonify({ return jsonify({
"logged_in": True, "logged_in": True,
"user": {"id": session["user_id"], "username": session["username"], "display_name": session["display_name"], "role": session["role"]}, "user": {"id": session["user_id"], "username": session["username"], "display_name": session["display_name"], "role": session["role"]},
"tenants": session.get("tenants", []), "tenants": tenants,
}) })
@@ -487,7 +497,7 @@ def init_db():
_exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""", _exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""",
("wuji", generate_password_hash("wuji123", "pbkdf2:sha256"), "无界负责人", "opc_owner", date.today().isoformat())) ("wuji", generate_password_hash("wuji123", "pbkdf2:sha256"), "无界负责人", "opc_owner", date.today().isoformat()))
# 各 OPC 负责人绑定工作台 # 各 OPC 负责人绑定工作台
for uname, tenant in [("kepu","科普·无界"),("keyan","科研·无界"),("yihuan","医患·无界"),("mcn","MCN·无界"),("wuji","无界·无界")]: for uname, tenant in [("kepu","科普·无界"),("keyan","科研·无界"),("yihuan","医患·无界"),("mcn","MCN·无界"),("wuji","学会·无界")]:
u = one(conn, "SELECT id FROM users WHERE username=?", (uname,)) u = one(conn, "SELECT id FROM users WHERE username=?", (uname,))
if u: if u:
_exec(conn, "INSERT INTO user_tenants (user_id, tenant) VALUES (?,?)", (u["id"], tenant)) _exec(conn, "INSERT INTO user_tenants (user_id, tenant) VALUES (?,?)", (u["id"], tenant))
@@ -685,7 +695,7 @@ def attach_common(conn, resource, items):
def monthly_finance(conn, tenant="科普·无界"): def monthly_finance(conn, tenant="科普·无界"):
months = [f"2026-{m:02d}" for m in range(1, 13)] months = [f"2026-{m:02d}" for m in range(1, 13)]
pfs = rows(conn, pfs = rows(conn,
"SELECT sign_amount, sign_month, status, budget_data FROM project_finances WHERE tenant=?", "SELECT sign_amount, sign_month, status, budget_data FROM project_finances WHERE tenant=? AND status='已签约'",
[tenant]) [tenant])
# 预解析 budget_data{pf_index: {month_key: {rev, gross, payment, cost}}} # 预解析 budget_data{pf_index: {month_key: {rev, gross, payment, cost}}}
@@ -722,7 +732,7 @@ def monthly_finance(conn, tenant="科普·无界"):
data.append({ data.append({
"month": month, "revenue": revenue, "month": month, "revenue": revenue,
"labor": 0, "expense": 0, "purchase": 0, "labor": 0, "expense": 0, "purchase": 0,
"net_profit": gross, "gross": gross,
"sign": sign, "payment": payment, "cost": cost, "sign": sign, "payment": payment, "cost": cost,
}) })
return data return data
@@ -742,10 +752,88 @@ def bootstrap():
tenant = request.args.get("tenant", session.get("tenants", ["科普·无界"])[0]) tenant = request.args.get("tenant", session.get("tenants", ["科普·无界"])[0])
# 验证用户是否有权限访问该 workbench # 验证用户是否有权限访问该 workbench
allowed = session.get("tenants", []) allowed = session.get("tenants", [])
if "总工作台" not in allowed:
allowed = ["总工作台"] + allowed
if tenant not in allowed: if tenant not in allowed:
tenant = allowed[0] tenant = allowed[0]
conn = db() conn = db()
try: 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]
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}"),
"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]),
"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]),
"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]),
"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]),
})
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]))
# 合并 metrics
agg = {}
for key in ["total_projects","total_proposals","total_products","upcoming_products","signed_amount","signed_annual","signed_q2","signed_month","revenue_annual","revenue_q2","monthly_revenue","gross_annual","gross_q2","monthly_net_profit","payment_annual","payment_q2","payment_month","cost_annual","cost_q2","cost_month"]:
agg[key] = sum(m.get(key, 0) for m in all_metrics)
# 合并 monthly finance按月累加
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): def q(sql, *args):
return rows(conn, sql, args) return rows(conn, sql, args)
sales = attach_common(conn, "sales", q("SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", tenant)) sales = attach_common(conn, "sales", q("SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", tenant))
@@ -777,22 +865,33 @@ def bootstrap():
total += float(b.get(field) or 0) total += float(b.get(field) or 0)
return total return total
# 本季度月份范围Q1=1-3, Q2=4-6, Q3=7-9, Q4=10-12基于当前月
_now_month = date.today().month
_q_start = ((_now_month - 1) // 3) * 3 + 1
_q_range = range(_q_start, _q_start + 3)
rev_annual = sum_budget("rev", range(1, 13)) rev_annual = sum_budget("rev", range(1, 13))
gross_annual = sum_budget("gross", range(1, 13)) gross_annual = sum_budget("gross", range(1, 13))
rev_q2 = sum_budget("rev", range(4, 7)) rev_q2 = sum_budget("rev", _q_range)
gross_q2 = sum_budget("gross", range(4, 7)) gross_q2 = sum_budget("gross", _q_range)
rev_month = sum_budget("rev", [6]) rev_month = sum_budget("rev", [_now_month])
gross_month = sum_budget("gross", [6]) gross_month = sum_budget("gross", [_now_month])
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])
# Contract aggregates — from project_finances (经营管理项目) # Contract aggregates — from project_finances (经营管理项目)
def pf_status_sum(status): def pf_status_sum(status):
return sum(x["sign_amount"] or 0 for x in pfs if x["status"] == status) return sum(x["sign_amount"] or 0 for x in pfs if x["status"] == status)
signed_amount = pf_status_sum("已签约") signed_amount = pf_status_sum("已签约")
# 年度签约 = 所有已签约项目 2026 年的签约金额 # 年度签约 = 所有已签约项目 2026 年的签约金额
signed_annual = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约") signed_annual = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约")
# Q2 签约 = 签约月份在 2026-04~2026-06 的已签约项目 # 本季度签约 = 签约月份在当前季度的已签约项目
signed_q2 = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in ["2026-04","2026-05","2026-06"]) _q_months = [f"2026-{m:02d}" for m in _q_range]
# 本月签约 = 签约月份为 2026-06 的已签约项目 signed_q2 = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in _q_months)
signed_month = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "") == "2026-06") # 本月签约 = 签约月份为当月的已签约项目
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}")
pipeline_amount = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_status"] not in ["已签约","已丢单","已归档","已完成"]) 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) 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 = { summary = {
@@ -819,6 +918,12 @@ def bootstrap():
"revenue_q2": rev_q2, "revenue_q2": rev_q2,
"gross_annual": gross_annual, "gross_annual": gross_annual,
"gross_q2": gross_q2, "gross_q2": gross_q2,
"payment_annual": payment_annual,
"payment_q2": payment_q2,
"payment_month": payment_month,
"cost_annual": cost_annual,
"cost_q2": cost_q2,
"cost_month": cost_month,
"signed_not_executed": signed_not_executed, "signed_not_executed": signed_not_executed,
}, },
"recent": q("SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 8", tenant), "recent": q("SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 8", tenant),
@@ -833,10 +938,10 @@ TABLES = {
"sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]), "sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]),
"proposals": ("business_proposals", ["customer_or_project_name", "version", "description", "status", "created_date", "proposal_type", "notes", "tenant"]), "proposals": ("business_proposals", ["customer_or_project_name", "version", "description", "status", "created_date", "proposal_type", "notes", "tenant"]),
"operations": ("operation_projects", ["project_name", "project_version", "project_type", "project_status", "current_stage", "owner", "target_customer", "customer_need", "expected_contract_amount", "expected_sign_date", "sign_probability", "next_action", "sop_stage", "execution_progress", "current_deliverable", "risks", "notes", "tenant"]), "operations": ("operation_projects", ["project_name", "project_version", "project_type", "project_status", "current_stage", "owner", "target_customer", "customer_need", "expected_contract_amount", "expected_sign_date", "sign_probability", "next_action", "sop_stage", "execution_progress", "current_deliverable", "risks", "notes", "tenant"]),
"products": ("product_versions", ["product_name", "version", "version_goal", "feature_list", "launch_date", "status", "platform", "notes", "tenant"]), "products": ("product_versions", ["product_name", "version", "version_goal", "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"]), "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"]), "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", "budget_data"]), "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"]),
} }
@@ -890,6 +995,22 @@ def update_resource(resource, item_id):
valid_statuses = ["未开始", "进行中", "已结束"] valid_statuses = ["未开始", "进行中", "已结束"]
if not payload["status"] or payload["status"] not in valid_statuses: if not payload["status"] or payload["status"] not in valid_statuses:
payload["status"] = "未开始" payload["status"] = "未开始"
# 产品日期约束4 个时间不能早于启动时间;启动时间不能清空
if resource == "products":
# 查当前记录的 start_date
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] update_cols = [col for col in cols if col in payload]
if update_cols: if update_cols:
_exec(conn, _exec(conn,
@@ -1029,11 +1150,12 @@ def delete_file(file_id):
@app.route("/api/health") @app.route("/api/health")
def health(): def health():
return jsonify({"ok": True, "db": str(DB_PATH)}) return jsonify({"ok": True, "service": "opc-manager"})
init_db() from migrations import run_migrations
seed_db()
run_migrations()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -0,0 +1,29 @@
"""migrations/__init__.py — 数据库自愈机制入口
应用启动时调用 run_migrations(),自动:
1. 建表CREATE TABLE IF NOT EXISTS
2. 加列SHOW COLUMNS 检查后 ALTER TABLE ADD COLUMN
3. 数据修正UPDATE 修复脏数据/变更枚举值)
4. 初始化默认用户和示例数据(仅空库时)
参考 SalesManager 的 migrations/ 模式,所有迁移函数幂等可重复执行。
"""
def run_migrations():
"""执行所有迁移(顺序执行,幂等)
延迟 import 避免 circular importmigrations 各子模块依赖 flask_app 的 db/_exec 等)。
"""
from migrations.tables import migrate_create_tables
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.seed import migrate_seed_users, migrate_seed_demo_data
migrate_create_tables()
migrate_add_columns()
migrate_fix_task_status()
migrate_rename_tenant()
migrate_drop_product_fields()
migrate_seed_users()
migrate_seed_demo_data()

View File

@@ -0,0 +1,81 @@
"""migrations/columns.py — 加列迁移(老表补字段,幂等)"""
def _add_column_if_missing(conn, table, column, ddl):
"""检查列是否存在,不存在才加(幂等)"""
from flask_app import _exec, mysql, logger
cur = conn.cursor(dictionary=True)
cur.execute(f"SHOW COLUMNS FROM {table} LIKE %s", (column,))
exists = cur.fetchone()
cur.close()
if not exists:
try:
_exec(conn, ddl)
print(f"[migrate] {table}.{column} 列已添加")
except mysql.connector.Error as e:
logger.debug(f"add column {table}.{column} skipped: {e}")
def migrate_add_columns():
"""为老表补齐后续新增的字段"""
from flask_app import db
conn = db()
try:
# tenant 字段(多工作台支持)
for table in ["sales_leads", "follow_up_records", "business_proposals",
"operation_projects", "product_versions", "finance_records",
"project_tasks"]:
_add_column_if_missing(conn, table, "tenant",
f"ALTER TABLE {table} ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'")
# business_proposals 扩展字段
_add_column_if_missing(conn, "business_proposals", "proposal_type",
"ALTER TABLE business_proposals ADD COLUMN proposal_type VARCHAR(100) NOT NULL DEFAULT '业务方案'")
_add_column_if_missing(conn, "business_proposals", "notes",
"ALTER TABLE business_proposals ADD COLUMN notes VARCHAR(2000) NOT NULL DEFAULT ''")
# product_versions 扩展字段
_add_column_if_missing(conn, "product_versions", "priority",
"ALTER TABLE product_versions ADD COLUMN priority VARCHAR(10) NOT NULL DEFAULT 'P2'")
_add_column_if_missing(conn, "product_versions", "start_date",
"ALTER TABLE product_versions ADD COLUMN start_date VARCHAR(30) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "product_versions", "plan_date",
"ALTER TABLE product_versions ADD COLUMN plan_date VARCHAR(30) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "product_versions", "dev_done_date",
"ALTER TABLE product_versions ADD COLUMN dev_done_date VARCHAR(30) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "product_versions", "test_date",
"ALTER TABLE product_versions ADD COLUMN test_date VARCHAR(30) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "product_versions", "devs",
"ALTER TABLE product_versions ADD COLUMN devs VARCHAR(500) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "product_versions", "testers",
"ALTER TABLE product_versions ADD COLUMN testers VARCHAR(500) NOT NULL DEFAULT ''")
# project_tasks 扩展字段
_add_column_if_missing(conn, "project_tasks", "status",
"ALTER TABLE project_tasks ADD COLUMN status VARCHAR(50) NOT NULL DEFAULT '未开始'")
_add_column_if_missing(conn, "project_tasks", "sort_order",
"ALTER TABLE project_tasks ADD COLUMN sort_order INT NOT NULL DEFAULT 0")
_add_column_if_missing(conn, "project_tasks", "priority",
"ALTER TABLE project_tasks ADD COLUMN priority VARCHAR(10) NOT NULL DEFAULT 'P2'")
# project_finances 12 个月度预算字段(确收/毛利/回款/费用)
for m in ["01","02","03","04","05","06","07","08","09","10","11","12"]:
for field in ["rev", "gross", "payment", "cost"]:
col = f"{field}_2026_{m}"
_add_column_if_missing(conn, "project_finances", col,
f"ALTER TABLE project_finances ADD COLUMN {col} DOUBLE NOT NULL DEFAULT 0")
# project_finances 总视图字段:已回款 / 应付 / 已付
_add_column_if_missing(conn, "project_finances", "total_payment",
"ALTER TABLE project_finances ADD COLUMN total_payment DOUBLE NOT NULL DEFAULT 0")
_add_column_if_missing(conn, "project_finances", "total_cost",
"ALTER TABLE project_finances ADD COLUMN total_cost DOUBLE NOT NULL DEFAULT 0")
_add_column_if_missing(conn, "project_finances", "total_paid",
"ALTER TABLE project_finances ADD COLUMN total_paid DOUBLE NOT NULL DEFAULT 0")
conn.commit()
print("[migrate] 加列迁移完成")
finally:
conn.close()

View File

@@ -0,0 +1,73 @@
"""migrations/data_fixes.py — 数据修正迁移(修复脏数据、变更枚举值)"""
def migrate_fix_task_status():
"""修正 project_tasks 中非法的 status 值"""
from flask_app import db, _exec, mysql, logger
conn = db()
try:
fixes = [
"UPDATE project_tasks SET status='未开始' WHERE status='' OR status IS NULL",
"UPDATE project_tasks SET status='已结束' WHERE status='done'",
"UPDATE project_tasks SET status='进行中' WHERE status='验收中'",
]
for sql in fixes:
try:
cur = _exec(conn, sql)
affected = cur.rowcount
cur.close()
if affected:
print(f"[migrate] 修正 {affected} 条任务状态")
except mysql.connector.Error as e:
logger.warning(f"task status fix skipped: {e}")
conn.commit()
finally:
conn.close()
def migrate_rename_tenant():
"""工作台重命名:无界·无界 → 学会·无界"""
from flask_app import db, _exec, mysql
conn = db()
try:
tables = ["user_tenants", "sales_leads", "follow_up_records", "business_proposals",
"operation_projects", "product_versions", "finance_records", "project_tasks",
"project_finances"]
for table in tables:
try:
cur = _exec(conn, f"UPDATE {table} SET tenant='学会·无界' WHERE tenant='无界·无界'")
affected = cur.rowcount
cur.close()
if affected:
print(f"[migrate] {table}: {affected} 条记录 tenant 改为 '学会·无界'")
except mysql.connector.Error:
pass
conn.commit()
finally:
conn.close()
def migrate_drop_product_fields():
"""删除 product_versions 表的 owner / platform / feature_list 字段"""
from flask_app import db, mysql
conn = db()
try:
for col in ["owner", "platform", "feature_list"]:
cur = conn.cursor(dictionary=True)
cur.execute("SHOW COLUMNS FROM product_versions LIKE %s", (col,))
exists = cur.fetchone()
cur.close()
if exists:
try:
cur = conn.cursor()
cur.execute(f"ALTER TABLE product_versions DROP COLUMN {col}")
cur.close()
conn.commit()
print(f"[migrate] product_versions.{col} 列已删除")
except mysql.connector.Error as e:
print(f"[migrate] 删除 {col} 失败: {e}")
finally:
conn.close()

View File

@@ -0,0 +1,55 @@
"""migrations/seed.py — 初始化默认用户和示例数据(仅在空库时执行)"""
from datetime import date
def migrate_seed_users():
"""初始化默认用户和工作台权限(仅空库时执行)"""
from flask_app import db, _exec, one, generate_password_hash
conn = db()
try:
if one(conn, "SELECT id FROM users LIMIT 1"):
return # 已有用户,跳过
default_users = [
("qiukai", "yxcowork2026", "qiukai", "admin"),
("kepu", "kepu123", "科普负责人", "opc_owner"),
("keyan", "keyan123", "科研负责人", "opc_owner"),
("yihuan", "yihuan123", "医患负责人", "opc_owner"),
("mcn", "mcn123", "MCN负责人", "opc_owner"),
("wuji", "wuji123", "无界负责人", "opc_owner"),
]
for username, pwd, display, role in default_users:
_exec(conn, "INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)",
(username, generate_password_hash(pwd, "pbkdf2:sha256"), display, role, date.today().isoformat()))
# 绑定工作台
tenant_map = [
("kepu", "科普·无界"), ("keyan", "科研·无界"), ("yihuan", "医患·无界"),
("mcn", "MCN·无界"), ("wuji", "学会·无界"),
]
for uname, tenant in tenant_map:
u = one(conn, "SELECT id FROM users WHERE username=?", (uname,))
if u:
_exec(conn, "INSERT INTO user_tenants (user_id, tenant) VALUES (?,?)", (u["id"], tenant))
conn.commit()
print("[migrate] 默认用户已初始化")
finally:
conn.close()
def migrate_seed_demo_data():
"""填充初始示例数据(仅在空库时执行)"""
from flask_app import db, one, seed_db
conn = db()
try:
if one(conn, "SELECT id FROM sales_leads LIMIT 1"):
return # 已有数据,跳过
finally:
conn.close()
seed_db()
print("[migrate] 示例数据已填充")

View File

@@ -0,0 +1,162 @@
"""migrations/tables.py — 建表迁移(所有表的 CREATE TABLE IF NOT EXISTS"""
def migrate_create_tables():
"""确保所有业务表存在(幂等)"""
from flask_app import db, _exec, mysql, logger
conn = db()
try:
tables = [
"""CREATE TABLE IF NOT EXISTS sales_leads (
id INT AUTO_INCREMENT PRIMARY KEY,
target_customer VARCHAR(1000) NOT NULL,
priority VARCHAR(1000) NOT NULL DEFAULT 'P1',
status VARCHAR(1000) NOT NULL DEFAULT '待跟进',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""",
"""CREATE TABLE IF NOT EXISTS follow_up_records (
id INT AUTO_INCREMENT PRIMARY KEY,
target_type VARCHAR(1000) NOT NULL,
target_id INT NOT NULL,
followed_at VARCHAR(1000) NOT NULL DEFAULT '',
follower VARCHAR(1000) NOT NULL DEFAULT '慰心',
follow_up_method VARCHAR(1000) NOT NULL DEFAULT '记录',
content VARCHAR(1000) NOT NULL DEFAULT '',
next_action VARCHAR(1000) NOT NULL DEFAULT '',
next_follow_up_at VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""",
"""CREATE TABLE IF NOT EXISTS business_proposals (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_or_project_name VARCHAR(1000) NOT NULL,
version VARCHAR(1000) NOT NULL,
description VARCHAR(1000) NOT NULL DEFAULT '',
status VARCHAR(1000) NOT NULL DEFAULT '草稿',
created_date VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""",
"""CREATE TABLE IF NOT EXISTS operation_projects (
id INT AUTO_INCREMENT PRIMARY KEY,
project_name VARCHAR(1000) NOT NULL,
project_version VARCHAR(1000) NOT NULL DEFAULT 'v1.0',
project_type VARCHAR(1000) NOT NULL DEFAULT 'opportunity',
project_status VARCHAR(1000) NOT NULL DEFAULT '',
current_stage VARCHAR(1000) NOT NULL DEFAULT '',
owner VARCHAR(1000) NOT NULL DEFAULT '慰心',
start_date VARCHAR(1000) NOT NULL DEFAULT '',
end_date VARCHAR(1000) NOT NULL DEFAULT '',
target_customer VARCHAR(1000) NOT NULL DEFAULT '',
customer_need VARCHAR(1000) NOT NULL DEFAULT '',
expected_contract_amount DOUBLE NOT NULL DEFAULT 0,
expected_sign_date VARCHAR(1000) NOT NULL DEFAULT '',
sign_probability DOUBLE NOT NULL DEFAULT 0,
next_action VARCHAR(1000) NOT NULL DEFAULT '',
related_business_proposal_id INTEGER,
sop_file_id INTEGER,
sop_stage VARCHAR(1000) NOT NULL DEFAULT '',
execution_progress DOUBLE NOT NULL DEFAULT 0,
current_deliverable VARCHAR(1000) NOT NULL DEFAULT '',
risks VARCHAR(1000) NOT NULL DEFAULT '',
notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""",
"""CREATE TABLE IF NOT EXISTS product_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
product_name VARCHAR(1000) NOT NULL,
version VARCHAR(1000) NOT NULL,
version_goal VARCHAR(1000) NOT NULL DEFAULT '',
feature_list VARCHAR(1000) NOT NULL DEFAULT '',
launch_date VARCHAR(1000) NOT NULL DEFAULT '',
status VARCHAR(1000) NOT NULL DEFAULT '规划中',
notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""",
"""CREATE TABLE IF NOT EXISTS finance_records (
id INT AUTO_INCREMENT PRIMARY KEY,
month VARCHAR(1000) NOT NULL,
project_name VARCHAR(1000) NOT NULL DEFAULT '科普(慰心斋)',
record_type VARCHAR(1000) NOT NULL,
category VARCHAR(1000) NOT NULL DEFAULT '',
amount DOUBLE NOT NULL DEFAULT 0,
occurred_date VARCHAR(1000) NOT NULL DEFAULT '',
notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""",
"""CREATE TABLE IF NOT EXISTS file_assets (
id INT AUTO_INCREMENT PRIMARY KEY,
module VARCHAR(1000) NOT NULL,
owner_id INT NOT NULL,
owner_version VARCHAR(1000) NOT NULL DEFAULT '',
file_category VARCHAR(1000) NOT NULL DEFAULT '',
file_name VARCHAR(1000) NOT NULL,
file_type VARCHAR(1000) NOT NULL DEFAULT '',
file_size INTEGER NOT NULL DEFAULT 0,
file_path VARCHAR(1000) NOT NULL,
is_external INTEGER NOT NULL DEFAULT 0,
notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""",
"""CREATE TABLE IF NOT EXISTS project_tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INTEGER NOT NULL,
phase VARCHAR(1000) NOT NULL DEFAULT '',
milestone VARCHAR(1000) NOT NULL DEFAULT '',
task VARCHAR(1000) NOT NULL DEFAULT '',
owner VARCHAR(1000) NOT NULL DEFAULT '',
due_date VARCHAR(1000) NOT NULL DEFAULT '',
blockers VARCHAR(1000) NOT NULL DEFAULT '',
notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""",
"""CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'opc_owner',
created_at VARCHAR(30) NOT NULL DEFAULT ''
)""",
"""CREATE TABLE IF NOT EXISTS user_tenants (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
tenant VARCHAR(100) NOT NULL,
UNIQUE KEY (user_id, tenant)
)""",
"""CREATE TABLE IF NOT EXISTS project_finances (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界',
project_id VARCHAR(100) NOT NULL DEFAULT '',
business_type VARCHAR(100) NOT NULL DEFAULT '',
customer_name VARCHAR(200) NOT NULL DEFAULT '',
sign_amount DOUBLE NOT NULL DEFAULT 0,
sign_month VARCHAR(20) NOT NULL DEFAULT '',
status VARCHAR(50) NOT NULL DEFAULT '待签约',
sales_person VARCHAR(100) NOT NULL DEFAULT '',
total_rev DOUBLE NOT NULL DEFAULT 0,
total_gross DOUBLE NOT NULL DEFAULT 0,
budget_data TEXT,
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""",
]
for ddl in tables:
try:
_exec(conn, ddl)
conn.commit()
except mysql.connector.Error as e:
logger.debug(f"create table skipped: {e}")
conn.commit()
print("[migrate] 所有业务表已就绪")
finally:
conn.close()

193
deploy/README.md Normal file
View File

@@ -0,0 +1,193 @@
# OPC-Manager 自动化部署指南
## 架构
```
开发者 push main → Gitea 仓库 (git.qiukai.me)
Gitea Actions 触发
Runner跑在业务服务器 82.157.208.197 上)
git clone → rsync 到 release 目录
创建 venv → pip install → systemctl restart
健康检查 → 切换 current 软链 → 清理旧版本
```
## 服务器目录结构
```
/opt/opc-manager/
├── releases/
│ ├── abc1234/ ← 本次发布commit sha
│ ├── def5678/ ← 上次发布
│ └── ... ← 保留最近 5 个
├── shared/
│ ├── .env ← 环境变量(不进 git持久化
│ └── uploads/ ← 上传的文件(持久化,跨版本共享)
└── current → releases/abc1234 ← 软链,指向当前生效版本
```
## 一次性准备(在业务服务器上执行)
### 1. 安装 Gitea Actions Runner
```bash
# 下载 act_runner
cd /opt
wget https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64 -O act_runner
chmod +x act_runner
mv act_runner /usr/local/bin/
# 注册 runner到 git.qiukai.me
# 先在 Gitea 网页:仓库设置 → Actions → Runners → New runner获取 token
act_runner register \
--instance https://git.qiukai.me \
--token <YOUR_RUNNER_TOKEN> \
--name prod-deploy \
--labels prod-deploy \
--no-interactive
# 安装为系统服务
act_runner daemon --config /etc/act_runner/config.yaml &
# 或用 systemd
cat > /etc/systemd/system/act-runner.service <<'EOF'
[Unit]
Description=Gitea Actions Runner
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/act_runner daemon
Restart=on-failure
Environment=HOME=/root
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now act-runner
```
### 2. 创建部署目录结构
```bash
mkdir -p /opt/opc-manager/{releases,shared/uploads}
```
### 3. 创建 .env 文件
```bash
cat > /opt/opc-manager/shared/.env <<'EOF'
SECRET_KEY=改成一串随机字符串_至少32位
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=opc
DB_PASSWORD=opc123456
DB_NAME=opc
FLASK_DEBUG=false
EOF
chmod 600 /opt/opc-manager/shared/.env
```
### 4. 安装 systemd service
```bash
# 从仓库的 deploy/opc-manager.service 复制
cat > /etc/systemd/system/opc-manager.service <<'EOF'
[Unit]
Description=OPC Manager (Flask)
After=network.target mysql.service
[Service]
Type=simple
WorkingDirectory=/opt/opc-manager/current
ExecStart=/opt/opc-manager/current/.venv/bin/gunicorn --preload -w 4 -b 0.0.0.0:5177 backend.flask_app:app
Restart=on-failure
RestartSec=5
EnvironmentFile=/opt/opc-manager/current/.env
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable opc-manager
```
### 5. 配置 Gitea Secret
在 Gitea 网页操作:
1. 进入仓库 `qiukai/opc-manager`
2. 设置 → Actions → Secrets → New Secret
3. Name: `DEPLOY_TOKEN`
4. Value: 你的 Gitea Personal Access Token需要有 repo 读权限)
- 生成路径:头像 → 设置 → 应用 → 生成令牌
### 6. 首次手动部署
push 代码前,先手动跑一次确保目录结构正确:
```bash
cd /opt/opc-manager
git clone --depth 1 --branch main https://git.qiukai.me/qiukai/opc-manager.git /tmp/opc-init
RELEASE_DIR=/opt/opc-manager/releases/initial
mkdir -p "${RELEASE_DIR}"
rsync -a --exclude='.git' --exclude='.env' --exclude='.venv' /tmp/opc-init/ "${RELEASE_DIR}/"
ln -sfn /opt/opc-manager/shared/.env "${RELEASE_DIR}/.env"
ln -sfn /opt/opc-manager/shared/uploads "${RELEASE_DIR}/data/uploads"
cd "${RELEASE_DIR}"
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
ln -sfn "${RELEASE_DIR}" /opt/opc-manager/current
systemctl start opc-manager
curl http://127.0.0.1:5177/api/health
rm -rf /tmp/opc-init
```
## 日常使用
### 发布新版本
```bash
# 本地
git push origin main
# 自动触发 Gitea Actions → 服务器自动部署
```
### 查看部署状态
```bash
# 在 Gitea 网页:仓库 → Actions 查看部署日志
# 或在服务器:
systemctl status opc-manager
ls -la /opt/opc-manager/current
```
### 回滚
```bash
# 列出历史版本
ls -t /opt/opc-manager/releases
# 切换到上一个版本
PREV=$(ls -t /opt/opc-manager/releases | sed -n '2p')
ln -sfn "/opt/opc-manager/releases/${PREV}" /opt/opc-manager/current
systemctl restart opc-manager
```
## Nginx 反代(可选)
```nginx
server {
listen 80;
server_name opc.yxcowork.vip;
location / {
proxy_pass http://127.0.0.1:5177;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```

View File

@@ -0,0 +1,14 @@
[Unit]
Description=OPC Manager (Flask)
After=network.target mysql.service
[Service]
Type=simple
WorkingDirectory=/opt/opc-manager/current
ExecStart=/opt/opc-manager/current/.venv/bin/gunicorn --preload -w 4 -b 0.0.0.0:5177 backend.flask_app:app
Restart=on-failure
RestartSec=5
EnvironmentFile=/opt/opc-manager/current/.env
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,82 @@
# OPC-Manager 自动化部署配置任务
## 背景
OPC-Manager 项目Flask + MySQL端口 5177需要配置 Gitea Actions 自动化部署。参考 SalesManager 的模式push 到 main 分支后自动部署到业务服务器。部署工作流文件已写好(`.gitea/workflows/deploy.yml`),需要在服务器上完成一次性准备。
## 服务器信息
- 业务服务器82.157.208.197SSH 别名 `ssh business`
- Gitea 地址https://git.qiukai.me
- 仓库qiukai/opc-manager
- 服务端口5177
- MySQL已安装8.0.45,系统服务),数据库 `opc`,用户 `opc/opc123456`
## 需要完成的 5 个步骤
### 1. 安装 Gitea Actions Runner
在业务服务器上安装 `act_runner` 并注册到 Gitea
- 下载地址https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64
- 放到 `/usr/local/bin/act_runner`
- 注册时 name=`prod-deploy`labels=`prod-deploy`
- 注册 token 需要到 Gitea 网页获取:仓库 qiukai/opc-manager → 设置 → Actions → Runners → New runner
- 安装为 systemd 服务(`/etc/systemd/system/act-runner.service`),开机自启
### 2. 创建部署目录结构
```bash
mkdir -p /opt/opc-manager/{releases,shared/uploads}
```
### 3. 创建 .env 文件
`/opt/opc-manager/shared/.env` 创建环境变量文件:
```
SECRET_KEY=随机生成32位以上字符串
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=opc
DB_PASSWORD=opc123456
DB_NAME=opc
FLASK_DEBUG=false
```
权限设为 600。
### 4. 安装 systemd service
创建 `/etc/systemd/system/opc-manager.service`
```ini
[Unit]
Description=OPC Manager (Flask)
After=network.target mysql.service
[Service]
Type=simple
WorkingDirectory=/opt/opc-manager/current
ExecStart=/opt/opc-manager/current/.venv/bin/gunicorn --preload -w 4 -b 0.0.0.0:5177 backend.flask_app:app
Restart=on-failure
RestartSec=5
EnvironmentFile=/opt/opc-manager/current/.env
[Install]
WantedBy=multi-user.target
```
执行 `systemctl daemon-reload && systemctl enable opc-manager`(先不 start等首次部署后自动启动
### 5. 配置 Gitea Secret
在 Gitea 网页操作(需要用户在浏览器完成):
- 进入仓库 qiukai/opc-manager → 设置 → Actions → Secrets → New Secret
- Name: `DEPLOY_TOKEN`
- Value: 用户的 Gitea Personal Access Token需 repo 读权限)
- 生成路径:头像 → 设置 → 应用 → 生成令牌
## 首次部署验证
准备完成后,在本地执行一次 `git push origin main`,观察:
1. Gitea 网页 Actions 页面是否有部署任务在运行
2. 部署日志是否正常
3. 部署完成后 `curl http://127.0.0.1:5177/api/health` 是否返回 `{"ok":true,"service":"opc-manager"}`
4. `systemctl status opc-manager` 是否 active
## 参考文件
项目的部署工作流在仓库的 `.gitea/workflows/deploy.yml`systemd 模板在 `deploy/opc-manager.service`,完整说明在 `deploy/README.md`
## 注意事项
- Gitea Runner 的 token 需要用户在浏览器获取后告诉你,你无法自动获取
- Gitea Secret (DEPLOY_TOKEN) 也需要用户在浏览器配置
- 如果服务器没有 python3.11+需要先安装OPC-Manager 要求 Python 3.9+
- 确保服务器已安装 git、rsync、curl一般都有

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
flask==3.1.3
mysql-connector-python==9.4.0
python-dotenv==1.2.1
werkzeug==3.1.8
gunicorn==23.0.0

View File

@@ -1,33 +1,25 @@
// app.js — 入口文件(加载模块 + 初始化) // app.js — 入口文件(加载模块 + 初始化)
// 所有业务逻辑已拆分到 modules/ 目录:
// utils.js — 共享状态、工具函数、API 封装
// home.js — 首页 + 财务趋势图
// projects.js — 重点工作与台账(项目+任务+拖拽)
// proposals.js — 业务方案 + 文件管理
// products.js — 产品迭代
// finance.js — 经营管理(财务)
// drawer.js — 详情抽屉 + 评论 + 转移
// Tab 点击委托
document.querySelector("#tabs").addEventListener("click", (event) => {
const button = event.target.closest("button[data-tab]");
if (button) switchTab(button.dataset.tab);
});
// 恢复上次的工作台和标签页 // 恢复上次的工作台和标签页
const savedTenant = localStorage.getItem("opc-active-tenant"); const savedTenant = localStorage.getItem("opc-active-tenant");
if (savedTenant) { if (savedTenant) {
state.tenant = savedTenant; state.tenant = savedTenant;
document.querySelectorAll(".workspace-nav-item").forEach(el => el.classList.toggle("active", el.dataset.tenant === savedTenant));
const label = savedTenant.replace("·无界", ""); const label = savedTenant.replace("·无界", "");
document.querySelector("#workspaceTitle").textContent = label + " OPC 工作台"; document.querySelector("#workspaceTitle").textContent = label + " OPC 工作台";
const tLabel = document.querySelector("#currentTenantLabel");
if (tLabel) { tLabel.textContent = label || "工作台"; tLabel.title = savedTenant; }
} }
const savedTab = localStorage.getItem("opc-active-tab"); const savedTab = localStorage.getItem("opc-active-tab");
// 初始化 // 初始化
applyUserTenants(); applyUserTenants();
updateSidebarTabs();
load().then(() => { load().then(() => {
if (savedTab && savedTab !== "home") switchTab(savedTab); if (state.tenant === "总工作台") {
switchTab("home");
} else if (savedTab && savedTab !== "home") {
switchTab(savedTab);
}
}).catch((error) => { }).catch((error) => {
document.querySelector("main").innerHTML = `<section class="card p-6 text-red-700">加载失败:${esc(error.message)}</section>`; document.querySelector("main").innerHTML = `<section class="card p-6 text-red-700">加载失败:${esc(error.message)}</section>`;
}); });

View File

@@ -1,4 +1,4 @@
// drawer.js — 详情抽屉 + 评论 + 转移 + 删除 // drawer.js — 详情抽屉 + 评论 + 删除
function drawerField(icon, label, name, value, multiline = false, customControl = null) { function drawerField(icon, label, name, value, multiline = false, customControl = null) {
const safeValue = esc(value || ""); const safeValue = esc(value || "");
@@ -23,7 +23,7 @@ function openDrawer(resource, id) {
? [["project_name","项目名称"],["owner","负责人"],["expected_sign_date","截止时间"],["expected_contract_amount","金额"],["notes","项目说明"]] ? [["project_name","项目名称"],["owner","负责人"],["expected_sign_date","截止时间"],["expected_contract_amount","金额"],["notes","项目说明"]]
: resource === "proposals" : resource === "proposals"
? [["customer_or_project_name","客户/项目"],["proposal_type","方案类型"],["notes","备注"]] ? [["customer_or_project_name","客户/项目"],["proposal_type","方案类型"],["notes","备注"]]
: [["product_name","产品名称"],["version","版本号"],["version_goal","版本目标"],["feature_list","核心功能"],["launch_date","上线日期"],["status","状态"],["notes","备注"]]; : [["product_name","版本名称"],["version","版本号"],["priority","优先级"],["version_goal","版本目标"],["start_date","启动时间"],["plan_date","产品方案"],["dev_done_date","研发完成"],["test_date","测试完成"],["launch_date","上线时间"],["notes","进展备注"]];
const fieldIcons = { const fieldIcons = {
target_customer: "user", priority: "flag", status: "circle-dot", target_customer: "user", priority: "flag", status: "circle-dot",
project_name: "briefcase-business", project_version: "git-branch", project_status: "circle-dot", current_stage: "map-pin", project_name: "briefcase-business", project_version: "git-branch", project_status: "circle-dot", current_stage: "map-pin",
@@ -31,29 +31,38 @@ function openDrawer(resource, id) {
sign_probability: "percent", sop_stage: "list-checks", execution_progress: "activity", sign_probability: "percent", sop_stage: "list-checks", execution_progress: "activity",
current_deliverable: "package", risks: "alert-triangle", next_action: "arrow-right", current_deliverable: "package", risks: "alert-triangle", next_action: "arrow-right",
product_name: "box", version: "tag", version_goal: "target", feature_list: "list", platform: "layers", product_name: "box", version: "tag", version_goal: "target", feature_list: "list", platform: "layers",
launch_date: "calendar", notes: "sticky-note", proposal_type: "tag", customer_or_project_name: "building" launch_date: "calendar", notes: "sticky-note", proposal_type: "tag", customer_or_project_name: "building",
priority: "flag", owner: "user", start_date: "play", plan_date: "file-text", dev_done_date: "check-square",
test_date: "bug", devs: "users", testers: "shield-check"
}; };
const multilineFields = ["customer_need", "current_deliverable", "risks", "next_action", "version_goal", "feature_list", "notes"]; const multilineFields = ["customer_need", "current_deliverable", "risks", "next_action", "version_goal", "feature_list", "notes"];
const followupTarget = resource === "sales" ? "sales" : resource === "proposals" ? "proposal" : resource === "operations" ? "operation" : resource === "products" ? "product" : ""; const followupTarget = resource === "sales" ? "sales" : resource === "proposals" ? "proposal" : resource === "operations" ? "operation" : resource === "products" ? "product" : "";
const title = esc(item.target_customer || item.project_name || (item.customer_or_project_name ? `${item.customer_or_project_name} · ${item.proposal_type || ''}` : "") || item.product_name); const title = esc(item.target_customer || item.project_name || (item.customer_or_project_name ? `${item.customer_or_project_name} · ${item.proposal_type || ''}` : "") || item.product_name);
const titleForAttr = esc(item.target_customer || item.project_name || (item.customer_or_project_name ? `${item.customer_or_project_name} · ${item.proposal_type || ''}` : "") || item.product_name); const titleForAttr = esc(item.target_customer || item.project_name || (item.customer_or_project_name ? `${item.customer_or_project_name} · ${item.proposal_type || ''}` : "") || item.product_name);
drawer.innerHTML = `<div class="drawer-panel"><div class="sticky top-0 z-10 flex items-center justify-between border-b border-slate-200 bg-white/95 px-5 py-3 backdrop-blur"><div><p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">Detail Drawer</p><div class="flex items-center gap-2"><h2 class="drawer-title text-[17px] font-semibold leading-6 text-slate-900">${title}</h2><span id="drawerSaveStatus" class="save-status"></span></div></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-blue-600 hover:bg-blue-50" onclick="openTransferModal('${resource}', ${id}, '${titleForAttr}')" ${resource === 'operations' ? '' : 'style="display:none"'}><i data-lucide="move-right"></i>转移</button><button class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" onclick="deleteDrawerItem('${resource}', ${id})"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm" onclick="closeDrawer()">关闭</button></div></div><div class="grid gap-5 p-5"> drawer.innerHTML = `<div class="drawer-panel"><div class="sticky top-0 z-10 flex items-center justify-between border-b border-slate-200 bg-white/95 px-5 py-3 backdrop-blur"><div><p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">Detail Drawer</p><div class="flex items-center gap-2"><h2 class="drawer-title text-[17px] font-semibold leading-6 text-slate-900">${title}</h2><span id="drawerSaveStatus" class="save-status"></span></div></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" onclick="deleteDrawerItem('${resource}', ${id})"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm" onclick="closeDrawer()">关闭</button></div></div><div class="grid gap-5 p-5">
${resource === "products" ? (() => {
const dDays = (s, e) => { if (!s || !e) return '-'; const d = Math.round((new Date(e) - new Date(s)) / 86400000); return d >= 0 ? d + ' 天' : '-'; };
return `<section>
<h3 class="drawer-section-title">耗时统计</h3>
<div class="grid grid-cols-2 gap-3">
<div class="bg-slate-50 rounded-lg p-3"><p class="text-xs text-slate-500">总耗时上线启动</p><p class="text-lg font-semibold text-slate-800 mt-1">${dDays(item.start_date, item.launch_date)}</p></div>
<div class="bg-slate-50 rounded-lg p-3"><p class="text-xs text-slate-500">产品耗时方案启动</p><p class="text-lg font-semibold text-slate-800 mt-1">${dDays(item.start_date, item.plan_date)}</p></div>
<div class="bg-slate-50 rounded-lg p-3"><p class="text-xs text-slate-500">研发耗时研发完成方案</p><p class="text-lg font-semibold text-slate-800 mt-1">${dDays(item.plan_date, item.dev_done_date)}</p></div>
<div class="bg-slate-50 rounded-lg p-3"><p class="text-xs text-slate-500">测试耗时测试完成研发完成</p><p class="text-lg font-semibold text-slate-800 mt-1">${dDays(item.dev_done_date, item.test_date)}</p></div>
</div>
</section>`;
})() : ""}
<section> <section>
<h3 class="drawer-section-title">属性</h3> <h3 class="drawer-section-title">属性</h3>
<form id="drawerForm" class="drawer-fields"> <form id="drawerForm" class="drawer-fields">
${resource === "operations" ? drawerField("map-pin", "当前阶段", "current_stage", "", false, `<select name="current_stage" class="form-ctrl" onchange="saveDrawerField(this,'${resource}',${id})">${["商务洽谈","系统上线","团队分工","项目交付","上线推广","结项验收"].map((s) => `<option ${s === item.current_stage ? "selected" : ""}>${s}</option>`).join("")}</select>`) : ""} ${resource === "operations" ? drawerField("map-pin", "当前阶段", "current_stage", "", false, `<select name="current_stage" class="form-ctrl" onchange="saveDrawerField(this,'${resource}',${id})">${["商务洽谈","系统上线","团队分工","项目交付","上线推广","结项验收"].map((s) => `<option ${s === item.current_stage ? "selected" : ""}>${s}</option>`).join("")}</select>`) : ""}
${fields.map(([key,label]) => { ${fields.map(([key,label]) => {
if (resource === "products" && key === "feature_list") { if (resource === "products" && key === "priority") {
const features = (item[key] || "").split("\n").filter(Boolean); return `<div class="drawer-field"><div class="drawer-field-label"><i data-lucide="flag"></i><span>优先级 / 状态</span></div><div class="drawer-field-control grid grid-cols-2 gap-3"><select name="priority" class="form-ctrl" onchange="saveDrawerField(this,'${resource}',${id})">${["P0","P1","P2","P3"].map((s) => `<option ${s === (item.priority||'P2') ? "selected" : ""}>${s}</option>`).join("")}</select><select name="status" class="form-ctrl" onchange="saveDrawerField(this,'${resource}',${id})">${["未开始","规划中","开发中","测试中","已上线","已取消"].map((s) => `<option ${s === (item.status||'规划中') ? "selected" : ""}>${s}</option>`).join("")}</select></div></div>`;
if (features.length === 0) features.push("");
return `<div class="drawer-field"><div class="drawer-field-label"><i data-lucide="list"></i><span>${label}</span></div><div class="drawer-field-control" data-field="feature_list" data-id="${id}"><div class="feature-list" id="featureList_${id}">${features.map((f,i) => `<div class="feature-item"><span class="feature-num">${i+1}.</span><input class="form-ctrl" value="${f.replace(/"/g,'&quot;')}" onchange="saveFeatureList(${id})"><button class="feature-del" onclick="event.preventDefault();removeFeature(${id},${i})"><i data-lucide="x" style="width:12px;height:12px"></i></button></div>`).join("")}</div><button class="btn btn-ghost btn-sm text-blue-600 mt-1" onclick="event.preventDefault();addFeature(${id})"><i data-lucide="plus" style="width:14px;height:14px"></i>添加功能</button></div></div>`;
} }
if (resource === "products" && key === "launch_date") { if (resource === "products" && (key === "start_date" || key === "plan_date" || key === "dev_done_date" || key === "test_date" || key === "launch_date")) {
return drawerField("calendar", label, key, item[key], false, `<input type="date" name="${key}" value="${item[key]||''}" class="form-ctrl" data-original="${item[key]||''}" onchange="saveDrawerField(this,'${resource}',${id})">`); return drawerField("calendar", label, key, item[key], false, `<input type="date" name="${key}" value="${item[key]||''}" class="form-ctrl" data-original="${item[key]||''}" onchange="saveDrawerField(this,'${resource}',${id})">`);
} }
if (resource === "products" && key === "status") {
return drawerField("circle-dot", label, key, "", false, `<select name="status" class="form-ctrl" onchange="saveDrawerField(this,'${resource}',${id})">${["规划中","开发中","测试中","已上线","已取消"].map((s) => `<option ${s === (item.status||'规划中') ? "selected" : ""}>${s}</option>`).join("")}</select>`);
}
return drawerField(fieldIcons[key] || "circle", label, key, item[key], multilineFields.includes(key)); return drawerField(fieldIcons[key] || "circle", label, key, item[key], multilineFields.includes(key));
}).join("")} }).join("")}
</form> </form>
@@ -172,33 +181,6 @@ window.deleteDrawerItem = async (resource, id) => {
} }
}; };
window.openTransferModal = (resource, id, title) => {
document.querySelector("#transfer-resource").value = resource;
document.querySelector("#transfer-id").value = id;
document.querySelector("#transfer-title-text").textContent = "将「" + title + "」转移到:";
document.querySelector("#transferModal").classList.remove("hidden");
};
window.closeTransferModal = () => {
document.querySelector("#transferModal").classList.add("hidden");
};
window.submitTransfer = async (event) => {
event.preventDefault();
const form = event.currentTarget;
const resource = form.querySelector('[name="transfer_resource"]').value;
const id = form.querySelector('[name="transfer_id"]').value;
const newTenant = form.querySelector('[name="transfer_tenant"]').value;
try {
await api(`/api/${resource}/${id}`, { method: "PUT", body: JSON.stringify({ data: { tenant: newTenant } }) });
closeTransferModal();
closeDrawer();
await load();
} catch (error) {
toast("转移失败:" + error.message, "error");
}
};
// Squire 富文本编辑器 // Squire 富文本编辑器
window.squireInstances = {}; window.squireInstances = {};
window.squireCmd = (cmd) => { window.squireCmd = (cmd) => {
@@ -255,6 +237,27 @@ window.deleteFollowup = async (event, followupId, resource, targetId) => {
window.saveDrawerField = async (el, resource, id) => { window.saveDrawerField = async (el, resource, id) => {
const name = el.name; const name = el.name;
const value = el.value; const value = el.value;
// 产品日期约束
if (resource === "products") {
const listKey = "products";
const product = (state.data[listKey] || []).find(x => x.id === id);
if (product) {
// 启动时间必填
if (name === "start_date" && !value) {
toast("启动时间为必填项", "error");
el.value = product.start_date || '';
el.focus();
return;
}
// 其他 4 个时间不能早于启动时间
if (["plan_date","dev_done_date","test_date","launch_date"].includes(name) && value && product.start_date && value < product.start_date) {
toast("该时间不能早于启动时间(" + product.start_date + "", "error");
el.value = product[name] || '';
el.focus();
return;
}
}
}
try { try {
await api(`/api/${resource}/${id}`, { method: "PUT", body: JSON.stringify({ data: { [name]: value } }) }); await api(`/api/${resource}/${id}`, { method: "PUT", body: JSON.stringify({ data: { [name]: value } }) });
const listKey = { sales: "sales", proposals: "proposals", operations: "operations", products: "products" }[resource]; const listKey = { sales: "sales", proposals: "proposals", operations: "operations", products: "products" }[resource];

View File

@@ -1,5 +1,7 @@
// finance.js — 经营管理(财务)模块 // finance.js — 经营管理(财务)模块
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")}`;
function renderFinance() { function renderFinance() {
const pfs = state.data.projectFinances || []; const pfs = state.data.projectFinances || [];
const ops = state.data.operations || []; const ops = state.data.operations || [];
@@ -71,47 +73,47 @@ 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"; const isRevView = state.finView !== "cashflow" && state.finView !== "overview" && state.finView !== "monthly";
const mCols = months.map(m => { const mCols = months.map(m => {
const b = budgetMap[m] || {}; const b = budgetMap[m] || {};
if (isRevView) { if (isRevView) {
const rev = b.rev || 0; const rev = b.rev || 0;
const gross = b.gross || 0; const gross = b.gross || 0;
return `<td class="p-2 text-right whitespace-nowrap"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`; return `<td class="p-2 text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
} else { } else {
const payment = b.payment || 0; const payment = b.payment || 0;
const cost = b.cost || 0; const cost = b.cost || 0;
return `<td class="p-2 text-right whitespace-nowrap"><span class="${payment ? 'text-amber-700 font-medium' : 'text-slate-300'}">${payment ? money(payment) : '—'}</span><br><span class="text-xs ${cost ? 'text-rose-600' : 'text-slate-300'}">${cost ? money(cost) : '—'}</span></td>`; return `<td class="p-2 text-center whitespace-nowrap align-middle"><span class="${payment ? 'text-amber-700 font-medium' : 'text-slate-300'}">${payment ? money(payment) : '—'}</span><br><span class="text-xs ${cost ? 'text-rose-600' : 'text-slate-300'}">${cost ? money(cost) : '—'}</span></td>`;
} }
}).join(""); }).join("");
const totalCol = (() => { const totalCol = (() => {
if (isRevView) { if (isRevView) {
const totalRev = pf.total_rev || 0; const totalRev = pf.total_rev || 0;
const totalGross = pf.total_gross || 0; const totalGross = pf.total_gross || 0;
return `<td class="p-2 text-right whitespace-nowrap font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`; return `<td class="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
} else { } else {
let totalPayment = 0, totalCost = 0; let totalPayment = 0, totalCost = 0;
try { JSON.parse(pf.budget_data || "[]").forEach(b => { totalPayment += parseFloat(b.payment||0)||0; totalCost += parseFloat(b.cost||0)||0; }); } catch (e) {} try { JSON.parse(pf.budget_data || "[]").forEach(b => { totalPayment += parseFloat(b.payment||0)||0; totalCost += parseFloat(b.cost||0)||0; }); } catch (e) {}
return `<td class="p-2 text-right whitespace-nowrap font-semibold"><span class="${totalPayment ? 'text-amber-700' : 'text-slate-300'}">${totalPayment ? money(totalPayment) : '—'}</span><br><span class="text-xs ${totalCost ? 'text-rose-600' : 'text-slate-300'}">${totalCost ? money(totalCost) : '—'}</span></td>`; return `<td class="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalPayment ? 'text-amber-700' : 'text-slate-300'}">${totalPayment ? money(totalPayment) : '—'}</span><br><span class="text-xs ${totalCost ? 'text-rose-600' : 'text-slate-300'}">${totalCost ? money(totalCost) : '—'}</span></td>`;
} }
})(); })();
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}<td class="p-2 text-sm text-slate-500 text-center">${esc(pf.sales_person) || "—"}</td><td class="p-2 text-sm text-slate-500 text-center">${esc(pf.owner) || "—"}</td></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><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>`;
}; };
document.querySelector("#finance").innerHTML = `<div class="grid gap-4"> document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
<div class="grid grid-cols-6 gap-3"> <div class="grid grid-cols-4 gap-3">
${[["已签项目","" + signed.length],["签约金额",money(sumSign)],["流程项目","" + inContract.length],["流程金额",money(sumContract)],["待签项目","" + pending.length],["待签金额",money(sumPending)]].map(([l,v]) => `<div class="bg-white rounded-lg border border-slate-200 p-3 text-center"><p class="text-xs text-slate-500">${l}</p><p class="text-xl font-bold text-slate-800">${v}</p></div>`).join("")} ${[["已签项目","" + 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("")}
</div> </div>
<div class="grid grid-cols-5 gap-3"> <div class="grid grid-cols-5 gap-3">
${[["本月确收",money(thisMonthRev)],["本月毛利",money(thisMonthGross)],["本月回款",money(monthPayment)],["本月费用",money(monthCost)],["本月现金流",money(monthCashflow)]].map(([l,v]) => `<div class="bg-white rounded-lg border border-slate-200 p-3 text-center"><p class="text-xs text-slate-500">${l}</p><p class="text-xl font-bold text-slate-800">${v}</p></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-end"><button class="btn btn-primary btn-sm" onclick="openFinanceModal()"><i data-lucide="plus"></i>新增财务项目</button></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-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 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>
</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="p-8 grid gap-6"><input type="hidden" name="pf_id" id="pf-id-input" value="">
<div id="financeTabInfo"> <div id="financeTabInfo">
@@ -141,7 +143,7 @@ function renderFinance() {
</div> </div>
</div> </div>
<div id="financeTabBudget" class="hidden"> <div id="financeTabBudget" class="hidden">
<div class="grid grid-cols-4 gap-3 mb-4" id="budgetSummary"> <div class="grid grid-cols-5 gap-3 mb-4" id="budgetSummary">
<div class="bg-blue-50 rounded-lg p-3 text-center border border-blue-100"> <div class="bg-blue-50 rounded-lg p-3 text-center border border-blue-100">
<p class="text-xs text-blue-600 font-medium">总确收</p> <p class="text-xs text-blue-600 font-medium">总确收</p>
<p class="text-lg font-bold text-blue-700" id="budgetTotalRev">¥0</p> <p class="text-lg font-bold text-blue-700" id="budgetTotalRev">¥0</p>
@@ -155,18 +157,66 @@ function renderFinance() {
<p class="text-lg font-bold text-amber-700" id="budgetTotalPayment">¥0</p> <p class="text-lg font-bold text-amber-700" id="budgetTotalPayment">¥0</p>
</div> </div>
<div class="bg-rose-50 rounded-lg p-3 text-center border border-rose-100" id="budgetTotalCostCard"> <div class="bg-rose-50 rounded-lg p-3 text-center border border-rose-100" id="budgetTotalCostCard">
<p class="text-xs text-rose-600 font-medium">总费用</p> <p class="text-xs text-rose-600 font-medium">总应付</p>
<p class="text-lg font-bold text-rose-700" id="budgetTotalCost">¥0</p> <p class="text-lg font-bold text-rose-700" id="budgetTotalCost">¥0</p>
</div> </div>
<div class="bg-purple-50 rounded-lg p-3 text-center border border-purple-100">
<p class="text-xs text-purple-600 font-medium">总已付</p>
<p class="text-lg font-bold text-purple-700" id="budgetTotalPaid">¥0</p>
</div>
</div> </div>
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="budgetTable"> <table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="budgetTable">
<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:140px">月份</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> <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:140px">月份</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="budgetTbody"></tbody> <tbody id="budgetTbody"></tbody>
</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 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>
${card(`<div class="flex items-center justify-between mb-3"><h3 class="font-bold text-slate-700">项目明细 <span class="text-slate-400 font-normal">(${pfs.length})</span></h3><div class="flex items-center gap-1" id="finViewToggle"><button class="btn btn-sm ${state.finView !== 'cashflow' ? 'btn-primary' : 'btn-ghost'} p-1.5" data-view="rev" onclick="setFinView('rev')" title="确收/毛利视图"><i data-lucide="trending-up" style="width:16px;height:16px"></i></button><button class="btn btn-sm ${state.finView === 'cashflow' ? 'btn-primary' : 'btn-ghost'} p-1.5" data-view="cashflow" onclick="setFinView('cashflow')" title="回款/费用视图"><i data-lucide="wallet" style="width:16px;height:16px"></i></button></div></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">项目名称</th><th class="p-2 text-center font-semibold">类型</th><th class="p-2 text-center font-semibold">状态</th><th class="p-2 text-center font-semibold">签约月份</th><th class="p-2 text-center font-semibold">签约金额</th>${monthLabels.map(l => `<th class="p-2 text-center font-semibold">${l}<br><span class="text-xs text-slate-400">${state.finView !== 'cashflow' ? '确收/毛利' : '回款/费用'}</span></th>`).join("")}<th class="p-2 text-center font-semibold">总计<br><span class="text-xs text-slate-400">${state.finView !== 'cashflow' ? '确收/毛利' : '回款/费用'}</span></th><th class="p-2 text-center font-semibold">商务负责人</th><th class="p-2 text-center font-semibold">经营负责人</th></tr></thead><tbody>${pfs.filter(x => x.status === state.finFilter).map(renderPfRow).join("")}</tbody></table></div>`, "p-4")} ${state.finView === 'monthly' ? (() => {
const allPfs = pfs.filter(x => x.status === state.finFilter);
const now = new Date();
const defaultMonth = now.getFullYear() + "-" + String(now.getMonth()+1).padStart(2,"0");
if (!state.finMonth) state.finMonth = defaultMonth;
const selMonth = state.finMonth;
// 收集所有有数据的月份 + 当前月
const monthSet = new Set([defaultMonth]);
allPfs.forEach(pf => { let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch(e) {} bd.forEach(b => { if (b.month) monthSet.add(b.month); }); });
const sortedMonths = [...monthSet].sort().reverse();
const monthOpts = sortedMonths.map(m => `<option value="${m}" ${m === selMonth ? 'selected' : ''}>${m}</option>`).join("");
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 rows = [];
let sumRev=0, sumPay=0, sumCost=0, sumPaid=0;
allPfs.forEach(pf => {
let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
const b = bd.find(x => (x.month||"") === selMonth) || {};
const rev = Math.round(parseFloat(b.rev||0)||0);
const payment = Math.round(parseFloat(b.payment||0)||0);
const cost = Math.round(parseFloat(b.cost||0)||0);
const paid = Math.round(parseFloat(b.paid||0)||0);
if (!rev && !payment && !cost && !paid) return;
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-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>`);
});
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");
})() : (() => {
const calcTotals = (pf) => {
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
let rev = 0, payment = 0, cost = 0;
budget.forEach(b => { rev += parseFloat(b.rev||0)||0; payment += parseFloat(b.payment||0)||0; cost += parseFloat(b.cost||0)||0; });
const paid = parseFloat(pf.total_paid) || 0;
return { rev: Math.round(rev), payment: Math.round(payment), cost: Math.round(cost), paid: Math.round(paid) };
};
const allPfs = pfs.filter(x => x.status === state.finFilter);
let sumRev=0, sumPay=0, sumCost=0, sumPaid=0;
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 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");
})()}
</div>`; </div>`;
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
} }
@@ -185,7 +235,7 @@ window.openFinanceModal = () => {
modal.classList.remove("hidden"); modal.classList.remove("hidden");
}; };
window.addBudgetRow = (month = '', rev = '', gross = '', payment = '', cost = '') => { window.addBudgetRow = (month = '', rev = '', gross = '', payment = '', cost = '', paid = '') => {
const tbody = document.querySelector("#budgetTbody"); const tbody = document.querySelector("#budgetTbody");
if (!tbody) return; if (!tbody) return;
const row = document.createElement("tr"); const row = document.createElement("tr");
@@ -194,6 +244,7 @@ window.addBudgetRow = (month = '', rev = '', gross = '', payment = '', cost = ''
<td><input name="budget_gross[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${gross}" oninput="updateBudgetSummary()"></td> <td><input name="budget_gross[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${gross}" oninput="updateBudgetSummary()"></td>
<td><input name="budget_payment[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${payment}" oninput="updateBudgetSummary()"></td> <td><input name="budget_payment[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${payment}" oninput="updateBudgetSummary()"></td>
<td><input name="budget_cost[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${cost}" oninput="updateBudgetSummary()"></td> <td><input name="budget_cost[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${cost}" oninput="updateBudgetSummary()"></td>
<td><input name="budget_paid[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${paid}" oninput="updateBudgetSummary()"></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();updateBudgetSummary()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></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();updateBudgetSummary()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>`;
tbody.appendChild(row); tbody.appendChild(row);
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
@@ -204,20 +255,24 @@ window.updateBudgetSummary = () => {
const grossEl = document.querySelector("#budgetTotalGross"); const grossEl = document.querySelector("#budgetTotalGross");
const paymentEl = document.querySelector("#budgetTotalPayment"); const paymentEl = document.querySelector("#budgetTotalPayment");
const costEl = document.querySelector("#budgetTotalCost"); const costEl = document.querySelector("#budgetTotalCost");
const paidEl = document.querySelector("#budgetTotalPaid");
if (!revEl || !grossEl) return; if (!revEl || !grossEl) return;
const revInputs = document.querySelectorAll('[name="budget_rev[]"]'); const revInputs = document.querySelectorAll('[name="budget_rev[]"]');
const grossInputs = document.querySelectorAll('[name="budget_gross[]"]'); const grossInputs = document.querySelectorAll('[name="budget_gross[]"]');
const paymentInputs = document.querySelectorAll('[name="budget_payment[]"]'); const paymentInputs = document.querySelectorAll('[name="budget_payment[]"]');
const costInputs = document.querySelectorAll('[name="budget_cost[]"]'); const costInputs = document.querySelectorAll('[name="budget_cost[]"]');
let totalRev = 0, totalGross = 0, totalPayment = 0, totalCost = 0; const paidInputs = document.querySelectorAll('[name="budget_paid[]"]');
let totalRev = 0, totalGross = 0, totalPayment = 0, totalCost = 0, totalPaid = 0;
revInputs.forEach(el => { totalRev += parseFloat(el.value) || 0; }); revInputs.forEach(el => { totalRev += parseFloat(el.value) || 0; });
grossInputs.forEach(el => { totalGross += parseFloat(el.value) || 0; }); grossInputs.forEach(el => { totalGross += parseFloat(el.value) || 0; });
paymentInputs.forEach(el => { totalPayment += parseFloat(el.value) || 0; }); paymentInputs.forEach(el => { totalPayment += parseFloat(el.value) || 0; });
costInputs.forEach(el => { totalCost += parseFloat(el.value) || 0; }); costInputs.forEach(el => { totalCost += parseFloat(el.value) || 0; });
paidInputs.forEach(el => { totalPaid += parseFloat(el.value) || 0; });
revEl.textContent = money(totalRev); revEl.textContent = money(totalRev);
grossEl.textContent = money(totalGross); grossEl.textContent = money(totalGross);
if (paymentEl) paymentEl.textContent = money(totalPayment); if (paymentEl) paymentEl.textContent = money(totalPayment);
if (costEl) costEl.textContent = money(totalCost); if (costEl) costEl.textContent = money(totalCost);
if (paidEl) paidEl.textContent = money(totalPaid);
}; };
window.initBudgetTable = (budgetData) => { window.initBudgetTable = (budgetData) => {
@@ -225,7 +280,7 @@ window.initBudgetTable = (budgetData) => {
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = ""; tbody.innerHTML = "";
const rows = budgetData || []; const rows = budgetData || [];
rows.forEach(r => addBudgetRow(r.month || '', r.rev || '', r.gross || '', r.payment || '', r.cost || '')); rows.forEach(r => addBudgetRow(r.month || '', r.rev || '', r.gross || '', r.payment || '', r.cost || '', r.paid || ''));
setTimeout(() => updateBudgetSummary(), 50); setTimeout(() => updateBudgetSummary(), 50);
}; };
@@ -313,8 +368,9 @@ window.createFinance = async (event) => {
const grosses = form.querySelectorAll('[name="budget_gross[]"]'); const grosses = form.querySelectorAll('[name="budget_gross[]"]');
const payments = form.querySelectorAll('[name="budget_payment[]"]'); const payments = form.querySelectorAll('[name="budget_payment[]"]');
const costs = form.querySelectorAll('[name="budget_cost[]"]'); const costs = form.querySelectorAll('[name="budget_cost[]"]');
const paids = form.querySelectorAll('[name="budget_paid[]"]');
const budgetRows = []; const budgetRows = [];
let totalRev = 0, totalGross = 0; let totalRev = 0, totalGross = 0, totalPaidFromBudget = 0;
for (let i = 0; i < months.length; i++) { for (let i = 0; i < months.length; i++) {
const m = months[i].value.trim(); const m = months[i].value.trim();
if (!m) continue; if (!m) continue;
@@ -322,13 +378,20 @@ window.createFinance = async (event) => {
const gross = parseFloat(grosses[i].value) || 0; const gross = parseFloat(grosses[i].value) || 0;
const payment = parseFloat(payments[i].value) || 0; const payment = parseFloat(payments[i].value) || 0;
const cost = parseFloat(costs[i].value) || 0; const cost = parseFloat(costs[i].value) || 0;
budgetRows.push({ month: m, rev, gross, payment, cost }); const paid = parseFloat(paids[i].value) || 0;
budgetRows.push({ month: m, rev, gross, payment, cost, paid });
totalRev += rev; totalRev += rev;
totalGross += gross; totalGross += gross;
totalPaidFromBudget += paid;
} }
data.budget_data = JSON.stringify(budgetRows); data.budget_data = JSON.stringify(budgetRows);
data.total_rev = totalRev; data.total_rev = totalRev;
data.total_gross = totalGross; data.total_gross = totalGross;
data.total_paid = totalPaidFromBudget;
let totalPayment = 0, totalCost = 0;
for (const r of budgetRows) { totalPayment += r.payment; totalCost += r.cost; }
data.total_payment = totalPayment;
data.total_cost = totalCost;
const pfId = data.pf_id; const pfId = data.pf_id;
delete data.pf_id; delete data.pf_id;
try { try {

View File

@@ -3,39 +3,50 @@
function renderHome() { function renderHome() {
const { summary, financeMonthly } = state.data; const { summary, financeMonthly } = state.data;
const m = summary.metrics; const m = summary.metrics;
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")}`;
const rows1 = [ const rows1 = [
["年度累计签约", money(m.signed_annual || m.signed_amount)], ["年度累计", moneyInt(m.signed_annual || m.signed_amount)],
["Q2 累计签约", money(m.signed_q2 || 0)], ["季度累计", moneyInt(m.signed_q2 || 0)],
["本月新增签约", money(m.signed_month || 0)], ["本月新增", moneyInt(m.signed_month || 0)],
]; ];
const rows2 = [ const rows2 = [
["年度累计确收", money(m.revenue_annual)], ["年度累计", moneyInt(m.revenue_annual)],
["Q2 累计确收", money(m.revenue_q2)], ["季度累计", moneyInt(m.revenue_q2)],
["本月新增确收", money(m.monthly_revenue)], ["本月新增", moneyInt(m.monthly_revenue)],
]; ];
const rows3 = [ const rows3 = [
["年度累计毛利", money(m.gross_annual)], ["年度累计", moneyInt(m.gross_annual)],
["Q2 累计毛利", money(m.gross_q2)], ["季度累计", moneyInt(m.gross_q2)],
["本月新增毛利", money(m.monthly_net_profit)], ["本月新增", moneyInt(m.monthly_net_profit)],
]; ];
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"); const rows4 = [
["年度累计", moneyInt(m.payment_annual || 0)],
["季度累计", moneyInt(m.payment_q2 || 0)],
["本月新增", moneyInt(m.payment_month || 0)],
];
const rows5 = [
["年度累计", moneyInt(m.cost_annual || 0)],
["季度累计", moneyInt(m.cost_q2 || 0)],
["本月新增", moneyInt(m.cost_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");
document.querySelector("#home").innerHTML = ` document.querySelector("#home").innerHTML = `
<div class="grid gap-5"> <div class="grid gap-5">
<div class="grid grid-cols-4 gap-3"> ${state.tenant === "总工作台" ? "" : `<div class="grid grid-cols-4 gap-3">
${[ ${[
["经营管理", m.total_projects, "finance"], ["经营管理", m.total_projects, "finance"],
["重点工作与台账", m.total_proposals, "projects"], ["重点工作与台账", m.total_proposals, "projects"],
["业务方案", m.total_products, "proposals"], ["业务方案", m.total_products, "proposals"],
["产品迭代", m.upcoming_products, "products"], ["产品迭代", m.upcoming_products, "products"],
].map(([label, value, tab]) => `<button class="metric-card" onclick="switchTab('${tab}')"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="gauge"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></button>`).join("")} ].map(([label, value, tab]) => `<button class="metric-card" onclick="switchTab('${tab}')"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="gauge"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></button>`).join("")}
</div> </div>`}
<div class="grid grid-cols-3 gap-5">${tblCard("合同金额", rows1)}${tblCard("确收金额", rows2)}${tblCard("确收毛利", rows3)}</div> <div class="grid grid-cols-5 gap-5">${tblCard("合同金额", rows1)}${tblCard("确收金额", rows2)}${tblCard("确收毛利", rows3)}${tblCard("回款金额", rows4)}${tblCard("费用金额", rows5)}</div>
<div class="grid grid-cols-3 gap-5"> <div class="grid grid-cols-3 gap-5">
${card(`<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="chartSign"></canvas></div>`, "p-4")} ${card(`<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="chartSign"></canvas></div>`, "p-4")}
${card(`<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="chartRev"></canvas></div>`, "p-4")} ${card(`<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="chartRev"></canvas></div>`, "p-4")}
${card(`<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与费用</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="chartCash"></canvas></div>`, "p-4")} ${card(`<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与费用</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="chartCash"></canvas></div>`, "p-4")}
</div> </div>
${card(`<h2 class="text-lg font-bold">近期动态</h2><div class="mt-4 grid gap-2">${summary.recent.map((r) => `<div class="flex items-start justify-between rounded-md bg-slate-50 px-3 py-2 text-sm group"><span class="break-words">${r.content}</span><div class="flex items-center gap-2 flex-shrink-0 ml-2"><span class="text-xs text-slate-400">${r.followed_at}</span><button class="btn btn-ghost btn-sm text-red-400 opacity-0 group-hover:opacity-100 p-0 w-5 h-5" onclick="event.preventDefault();deleteActivity(${r.id})" title="删除动态"><i data-lucide="x" style="width:14px;height:14px"></i></button></div></div>`).join("")}</div>`, "p-5")} ${card(`<h2 class="text-lg font-bold">近期动态</h2><div class="mt-4 grid gap-2">${summary.recent.map((r) => `<div class="flex items-start justify-between rounded-md bg-slate-50 px-3 py-2 text-sm group"><span class="break-words">${r.content}</span><div class="flex items-center gap-2 flex-shrink-0 ml-2"><span class="text-xs text-slate-400">${r.followed_at}</span><button class="btn btn-ghost btn-sm text-red-400 opacity-0 group-hover:opacity-100 p-0 w-5 h-5" onclick="event.preventDefault();deleteActivity(${r.id})" title="删除动态"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></div></div>`).join("")}</div>`, "p-5")}
</div> </div>
`; `;
renderCharts(financeMonthly); renderCharts(financeMonthly);
@@ -81,7 +92,7 @@ function renderCharts(data) {
type: "line", type: "line",
data: { labels, datasets: [ data: { labels, datasets: [
{ label: "确收", data: data.map((x) => x.revenue || 0), borderColor: "#2563eb", backgroundColor: "rgba(37,99,235,0.06)", fill: true, tension: 0.3 }, { label: "确收", data: data.map((x) => x.revenue || 0), borderColor: "#2563eb", backgroundColor: "rgba(37,99,235,0.06)", fill: true, tension: 0.3 },
{ label: "毛利", data: data.map((x) => x.net_profit || 0), borderColor: "#059669", backgroundColor: "rgba(5,150,105,0.06)", fill: true, tension: 0.3 }, { label: "毛利", data: data.map((x) => x.gross || 0), borderColor: "#059669", backgroundColor: "rgba(5,150,105,0.06)", fill: true, tension: 0.3 },
]}, ]},
options: baseOpts, options: baseOpts,
}); });

View File

@@ -39,13 +39,25 @@ window.openProductDrawer = () => {
<button class="task-close" onclick="closeProductDrawer()"><i data-lucide="x"></i></button> <button class="task-close" onclick="closeProductDrawer()"><i data-lucide="x"></i></button>
</div> </div>
<form class="task-drawer-form" onsubmit="submitProductDrawer(event)"> <form class="task-drawer-form" onsubmit="submitProductDrawer(event)">
<label class="task-field"><span>产品名称</span><input name="product_name" required class="form-ctrl"></label> <label class="task-field"><span>版本名称</span><input name="product_name" required class="form-ctrl"></label>
<label class="task-field"><span>版本号</span><input name="version" required class="form-ctrl"></label> <div class="grid grid-cols-2 gap-3">
<label class="task-field"><span>版本目标</span><textarea name="version_goal" rows="2" class="form-ctrl"></textarea></label> <label class="task-field"><span>版本</span><input name="version" required class="form-ctrl"></label>
<label class="task-field"><span>核心功能</span><div class="feature-list" id="newFeatureList"><div class="feature-item"><span class="feature-num">1.</span><input class="form-ctrl" value=""><button class="feature-del" onclick="event.preventDefault();removeNewFeature(this)"><i data-lucide="x" style="width:12px;height:12px"></i></button></div></div><button class="btn btn-ghost btn-sm text-blue-600 mt-1" onclick="event.preventDefault();addNewFeature()"><i data-lucide="plus" style="width:14px;height:14px"></i>添加功能</button></label> <label class="task-field"><span>优先级</span><select name="priority" class="form-ctrl"><option>P0</option><option selected>P1</option><option>P2</option><option>P3</option></select></label>
<label class="task-field"><span>上线日期</span><input name="launch_date" type="date" class="form-ctrl"></label> </div>
<label class="task-field"><span>状态</span><select name="status" class="form-ctrl"><option>规划中</option><option>开发中</option><option>测试中</option><option>已上线</option><option>已取消</option></select></label> <label class="task-field"><span>版本目标</span><textarea name="version_goal" rows="3" class="form-ctrl"></textarea></label>
<input type="hidden" name="feature_list" id="newFeatureListHidden"> <div class="grid grid-cols-2 gap-3">
<label class="task-field"><span>启动时间 <span class="text-red-500">*</span></span><input name="start_date" type="date" required class="form-ctrl"></label>
<label class="task-field"><span>产品方案</span><input name="plan_date" type="date" class="form-ctrl"></label>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="task-field"><span>研发完成</span><input name="dev_done_date" type="date" class="form-ctrl"></label>
<label class="task-field"><span>测试</span><input name="test_date" type="date" class="form-ctrl"></label>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="task-field"><span>上线时间</span><input name="launch_date" type="date" class="form-ctrl"></label>
<label class="task-field"><span>状态</span><select name="status" class="form-ctrl"><option>未开始</option><option>规划中</option><option>开发中</option><option>测试中</option><option>已上线</option><option>已取消</option></select></label>
</div>
<label class="task-field"><span>进展备注</span><textarea name="notes" rows="3" class="form-ctrl"></textarea></label>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-slate-100"> <div class="flex justify-end gap-2 mt-4 pt-3 border-t border-slate-100">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeProductDrawer()">取消</button> <button type="button" class="btn btn-ghost btn-sm" onclick="closeProductDrawer()">取消</button>
<button type="submit" class="btn btn-primary btn-sm">确认新增</button> <button type="submit" class="btn btn-primary btn-sm">确认新增</button>
@@ -63,7 +75,7 @@ window.cycleProductStatus = async (id) => {
const products = state.data.products || []; const products = state.data.products || [];
const product = products.find(x => x.id === id); const product = products.find(x => x.id === id);
if (!product) return; if (!product) return;
const statuses = ["规划中", "开发中", "测试中", "已上线", "已取消"]; const statuses = ["未开始", "规划中", "开发中", "测试中", "已上线", "已取消"];
const current = statuses.indexOf(product.status) >= 0 ? product.status : "规划中"; const current = statuses.indexOf(product.status) >= 0 ? product.status : "规划中";
const newStatus = statuses[(statuses.indexOf(current) + 1) % statuses.length]; const newStatus = statuses[(statuses.indexOf(current) + 1) % statuses.length];
try { try {
@@ -103,32 +115,26 @@ window.editProductDate = (event, id) => {
input.focus(); input.focus();
}; };
window.addNewFeature = () => { window.addNewFeature = () => {};
const list = document.querySelector("#newFeatureList"); window.removeNewFeature = () => {};
if (!list) return; window.addFeature = () => {};
const idx = list.children.length; window.removeFeature = () => {};
const div = document.createElement("div"); window.saveFeatureList = () => {};
div.className = "feature-item";
div.innerHTML = `<span class="feature-num">${idx+1}.</span><input class="form-ctrl" value=""><button class="feature-del" onclick="event.preventDefault();removeNewFeature(this)"><i data-lucide="x" style="width:12px;height:12px"></i></button>`;
list.appendChild(div);
if (window.lucide) window.lucide.createIcons();
};
window.removeNewFeature = (btn) => {
const div = btn.closest(".feature-item");
if (!div) return;
div.remove();
const list = document.querySelector("#newFeatureList");
if (list) list.querySelectorAll(".feature-num").forEach((el, i) => { el.textContent = (i+1) + "."; });
};
window.submitProductDrawer = async (event) => { window.submitProductDrawer = 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 featureInputs = form.querySelectorAll("#newFeatureList input"); // 约束4 个时间不能早于启动时间
data.feature_list = [...featureInputs].map(el => el.value.trim()).filter(Boolean).join("\n"); const startDate = data.start_date;
data.platform = ""; if (startDate) {
for (const f of ['plan_date', 'dev_done_date', 'test_date', 'launch_date']) {
if (data[f] && data[f] < startDate) {
toast("「" + ({plan_date:'产品方案',dev_done_date:'研发完成',test_date:'测试完成',launch_date:'上线时间'}[f]) + "」不能早于启动时间", "error");
return;
}
}
}
data.tenant = state.tenant; data.tenant = state.tenant;
try { try {
const result = await api("/api/products", { method: "POST", body: JSON.stringify({ data }) }); const result = await api("/api/products", { method: "POST", body: JSON.stringify({ data }) });
@@ -141,64 +147,198 @@ window.submitProductDrawer = async (event) => {
} }
}; };
window.addFeature = (id) => { window.addFeature = () => {};
const list = document.querySelector(`#featureList_${id}`); window.removeFeature = () => {};
if (!list) return; window.saveFeatureList = () => {};
const idx = list.children.length;
const div = document.createElement("div"); // 排序状态
div.className = "feature-item"; let productSort = { field: null, dir: 1 };
div.innerHTML = `<span class="feature-num">${idx+1}.</span><input class="form-ctrl" value="" onchange="saveFeatureList(${id})"><button class="feature-del" onclick="event.preventDefault();removeFeature(${id},${idx})"><i data-lucide="x" style="width:12px;height:12px"></i></button>`;
list.appendChild(div); window.sortProducts = (field) => {
if (window.lucide) window.lucide.createIcons(); if (productSort.field === field) {
saveFeatureList(id); productSort.dir = -productSort.dir;
} else {
productSort.field = field;
productSort.dir = 1;
}
renderProducts();
}; };
window.removeFeature = (id, idx) => { function sortItems(items) {
const list = document.querySelector(`#featureList_${id}`); if (!productSort.field) return items;
if (!list) return; const f = productSort.field;
const items = list.querySelectorAll(".feature-item"); const d = productSort.dir;
if (items[idx]) items[idx].remove(); const priorityOrder = { P0: 0, P1: 1, P2: 2, P3: 3 };
list.querySelectorAll(".feature-num").forEach((el, i) => { el.textContent = (i+1) + "."; }); return [...items].sort((a, b) => {
saveFeatureList(id); let va = a[f] || '', vb = b[f] || '';
}; if (f === 'priority') { va = priorityOrder[va] ?? 9; vb = priorityOrder[vb] ?? 9; }
if (va < vb) return -1 * d;
window.saveFeatureList = (id) => { if (va > vb) return 1 * d;
const list = document.querySelector(`#featureList_${id}`); return 0;
if (!list) return; });
const values = [...list.querySelectorAll("input")].map(el => el.value.trim()).filter(Boolean); }
const data = values.join("\n");
api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { feature_list: data } }) });
const product = (state.data.products || []).find(x => x.id === id);
if (product) product.feature_list = data;
};
function renderProducts() { function renderProducts() {
const items = state.data.products || []; const rawItems = state.data.products || [];
const items = sortItems(rawItems);
const priorityColor = { P0: "bg-red-100 text-red-700", P1: "bg-orange-100 text-orange-700", P2: "bg-blue-100 text-blue-700", P3: "bg-slate-100 text-slate-600" };
const sortIcon = (f) => {
if (productSort.field !== f) return '<i data-lucide="chevrons-up-down" style="width:12px;height:12px;opacity:0.3"></i>';
return productSort.dir > 0 ? '<i data-lucide="chevron-up" style="width:12px;height:12px"></i>' : '<i data-lucide="chevron-down" style="width:12px;height:12px"></i>';
};
const sortTh = (f, label, extra='') => `<th class="px-3 py-2.5 font-semibold align-middle whitespace-nowrap cursor-pointer hover:text-blue-600 select-none" onclick="sortProducts('${f}')"><span class="inline-flex items-center gap-1">${label}${sortIcon(f)}</span>${extra}</th>`;
document.querySelector("#products").innerHTML = ` document.querySelector("#products").innerHTML = `
<div class="grid gap-4"> <div class="grid gap-4">
<div class="flex justify-end"> <div class="flex justify-end">
<button class="btn btn-primary btn-sm" onclick="openProductDrawer()"><i data-lucide="plus"></i>新增产品版本</button> <button class="btn btn-primary btn-sm" onclick="openProductDrawer()"><i data-lucide="plus"></i>新增产品版本</button>
</div> </div>
<div class="grid grid-cols-3 gap-4"> <div class="card overflow-x-auto">
${items.map((p) => ` <table class="w-full text-sm">
<div class="bg-white rounded-xl border border-slate-200 p-4 cursor-pointer hover:shadow-lg hover:border-blue-200 transition-all" onclick="openDrawer('products', ${p.id})"> <thead class="bg-slate-50 border-b border-slate-200">
<div class="flex items-start justify-between"> <tr class="text-left text-xs text-slate-500 uppercase tracking-wider">
<h4 class="text-base font-semibold text-slate-800 leading-tight">${esc(p.product_name)}</h4> ${sortTh('version','版本号')}
<span class="status-badge status-${esc(p.status)}" onclick="event.stopPropagation(); cycleProductStatus(${p.id})" title="点击切换状态">${esc(p.status) || '规划中'}</span> ${sortTh('priority','优先级')}
</div> ${sortTh('product_name','版本名称')}
<div class="mt-2 flex items-center gap-2 text-xs text-slate-500"> ${sortTh('status','状态')}
<span class="font-medium">${esc(p.version)}</span> ${sortTh('start_date','启动时间')}
<span>·</span> ${sortTh('plan_date','产品方案')}
<span class="cursor-pointer hover:text-blue-600 transition-colors" onclick="event.stopPropagation(); editProductDate(event, ${p.id})">${esc(p.launch_date) || '—'}</span> ${sortTh('dev_done_date','研发完成')}
</div> ${sortTh('test_date','测试完成')}
<div class="mt-3 space-y-1"> ${sortTh('launch_date','上线时间')}
<p class="text-sm text-slate-700 mt-1.5 leading-relaxed">${esc(p.version_goal) || '—'}</p> <th class="px-3 py-2.5 font-semibold align-middle whitespace-nowrap">总耗时</th>
<div class="text-sm text-slate-600 mt-1.5 leading-relaxed whitespace-pre-line">${esc(p.feature_list) || '—'}</div> </tr>
</div> </thead>
</div> <tbody class="divide-y divide-slate-100">
`).join("")} ${items.length === 0 ? `<tr><td colspan="10" class="px-3 py-8 text-center text-slate-400">暂无产品版本,点击右上角新增</td></tr>` : items.map((p) => {
const days = (s, e) => { if (!s || !e) return '-'; const d = Math.round((new Date(e) - new Date(s)) / 86400000); return d >= 0 ? d + '天' : '-'; };
return `
<tr class="hover:bg-slate-50 cursor-pointer transition-colors" onclick="openDrawer('products', ${p.id})">
<td class="px-3 py-2.5 whitespace-nowrap text-slate-600 align-middle">${esc(p.version) || '—'}</td>
<td class="px-3 py-2.5 whitespace-nowrap align-middle"><span class="inline-block px-2 py-0.5 rounded text-xs font-medium ${priorityColor[p.priority] || priorityColor.P2}" onclick="event.stopPropagation();cyclePriority(${p.id})">${esc(p.priority) || 'P2'}</span></td>
<td class="px-3 py-2.5 font-medium text-slate-800 max-w-[180px] truncate align-middle" title="${esc(p.product_name)}">${esc(p.product_name) || '—'}</td>
<td class="px-3 py-2.5 whitespace-nowrap align-middle"><span class="status-badge status-${esc(p.status)}" onclick="event.stopPropagation();cycleProductStatus(${p.id})">${esc(p.status) || '规划中'}</span></td>
<td class="px-3 py-2.5 whitespace-nowrap text-slate-500 text-xs align-middle" onclick="event.stopPropagation()"><input type="date" value="${esc(p.start_date) || ''}" data-id="${p.id}" data-field="start_date" class="prod-date-input" onchange="saveProductDate(this)"></td>
<td class="px-3 py-2.5 whitespace-nowrap text-slate-500 text-xs align-middle" onclick="event.stopPropagation()"><input type="date" value="${esc(p.plan_date) || ''}" data-id="${p.id}" data-field="plan_date" class="prod-date-input" onchange="saveProductDate(this)"></td>
<td class="px-3 py-2.5 whitespace-nowrap text-slate-500 text-xs align-middle" onclick="event.stopPropagation()"><input type="date" value="${esc(p.dev_done_date) || ''}" data-id="${p.id}" data-field="dev_done_date" class="prod-date-input" onchange="saveProductDate(this)"></td>
<td class="px-3 py-2.5 whitespace-nowrap text-slate-500 text-xs align-middle" onclick="event.stopPropagation()"><input type="date" value="${esc(p.test_date) || ''}" data-id="${p.id}" data-field="test_date" class="prod-date-input" onchange="saveProductDate(this)"></td>
<td class="px-3 py-2.5 whitespace-nowrap text-slate-600 text-xs align-middle" onclick="event.stopPropagation()"><input type="date" value="${esc(p.launch_date) || ''}" data-id="${p.id}" data-field="launch_date" class="prod-date-input" onchange="saveProductDate(this)"></td>
<td class="px-3 py-2.5 whitespace-nowrap text-slate-500 text-xs align-middle">${days(p.start_date, p.launch_date)}</td>
</tr>`;
}).join("")}
</tbody>
</table>
</div> </div>
</div> </div>
<aside id="productDrawer" class="task-drawer"></aside> <aside id="productDrawer" class="task-drawer"></aside>
`; `;
if (window.lucide) window.lucide.createIcons();
} }
window.saveProductDate = async (input) => {
const id = Number(input.dataset.id);
const field = input.dataset.field;
const value = input.value;
// 直接从同一行 DOM 读取启动时间(不依赖 state 缓存)
const row = input.closest('tr');
const startDateInput = row && row.querySelector('[data-field="start_date"]');
const startDate = startDateInput ? startDateInput.value : '';
const product = (state.data.products || []).find(x => x.id === id);
if (!product) return;
// 启动时间必填
if (field === 'start_date' && !value) {
toast("启动时间为必填项", "error");
input.value = product.start_date || '';
input.focus();
return;
}
// 约束:除启动时间外的 4 个时间不能早于启动时间
if (field !== 'start_date' && value && startDate && value < startDate) {
toast("该时间不能早于启动时间(" + startDate + "", "error");
input.value = product[field] || '';
input.focus();
return;
}
// 启动时间变更后,如果其他时间早于新启动时间,清空
if (field === 'start_date' && value) {
const fields = ['plan_date', 'dev_done_date', 'test_date', 'launch_date'];
const toClear = {};
for (const f of fields) {
if (product[f] && product[f] < value) {
toClear[f] = '';
}
}
if (Object.keys(toClear).length > 0) {
try {
await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { ...toClear, start_date: value } }) });
Object.assign(product, toClear, { start_date: value });
toast("启动时间已更新," + Object.keys(toClear).length + " 个早于启动时间的日期已清空", "info");
renderProducts();
return;
} catch (e) {
toast("修改失败:" + e.message, "error");
return;
}
}
}
try {
await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { [field]: value } }) });
product[field] = value;
if (field === 'start_date' || field === 'launch_date') {
renderProducts();
}
} catch (e) {
toast("修改失败:" + e.message, "error");
}
};
window.editProductDateInline = (event, id, field) => {
event.stopPropagation();
const products = state.data.products || [];
const product = products.find(x => x.id === id);
if (!product) return;
const td = event.currentTarget;
const currentValue = product[field] || "";
const input = document.createElement("input");
input.type = "date";
input.className = "form-ctrl form-ctrl-sm w-full text-xs";
input.value = currentValue;
let saved = false;
const save = async () => {
if (saved) return;
saved = true;
const newValue = input.value;
try {
await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { [field]: newValue } }) });
product[field] = newValue;
td.innerHTML = newValue || '—';
} catch (e) {
toast("修改失败:" + e.message, "error");
td.innerHTML = currentValue || '—';
}
};
input.addEventListener("change", save);
input.addEventListener("blur", () => {
if (!saved) td.innerHTML = currentValue || '—';
});
td.innerHTML = "";
td.appendChild(input);
input.focus();
if (input.showPicker) {
try { input.showPicker(); } catch (e) {}
}
};
window.cyclePriority = async (id) => {
const products = state.data.products || [];
const product = products.find(x => x.id === id);
if (!product) return;
const levels = ["P0", "P1", "P2", "P3"];
const cur = levels.indexOf(product.priority) >= 0 ? product.priority : "P2";
const next = levels[(levels.indexOf(cur) + 1) % levels.length];
try {
await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { priority: next } }) });
product.priority = next;
renderProducts();
} catch (e) { toast("更新失败:" + e.message, "error"); }
};

View File

@@ -13,13 +13,57 @@ function applyUserTenants() {
e.stopPropagation(); e.stopPropagation();
toggleUserMenu(user); toggleUserMenu(user);
}); });
const allowedTenants = data.tenants || []; // 缓存可用工作台列表,供下拉菜单使用
document.querySelectorAll(".workspace-nav-item").forEach(el => { state.allowedTenants = data.tenants || [];
el.style.display = allowedTenants.includes(el.dataset.tenant) ? "" : "none"; updateTenantLabel();
});
}); });
} }
window.toggleTenantMenu = (event) => {
event.stopPropagation();
let menu = document.getElementById("tenantMenu");
if (menu) { menu.remove(); return; }
const btn = event.currentTarget;
const rect = btn.getBoundingClientRect();
const tenants = state.allowedTenants || [];
menu = document.createElement("div");
menu.id = "tenantMenu";
menu.className = "fixed bg-white rounded-lg shadow-xl border border-slate-200 py-1 min-w-[160px] z-[9999]";
menu.style.left = Math.min(rect.left - 8, window.innerWidth - 180) + "px";
menu.style.top = rect.bottom + 6 + "px";
menu.innerHTML = `
<div class="px-4 py-2 border-b border-slate-100">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">切换工作台</p>
</div>
${tenants.map(t => `
<button class="w-full text-left px-4 py-2 text-sm hover:bg-slate-50 transition-colors flex items-center justify-between gap-2 ${t === state.tenant ? 'text-blue-600 font-medium' : 'text-slate-700'}" onclick="switchTenantFromMenu('${t.replace(/'/g, "\\'")}')">
<span>${esc(t)}</span>
${t === state.tenant ? '<i data-lucide="check" style="width:14px;height:14px"></i>' : ''}
</button>
`).join('')}`;
document.body.appendChild(menu);
if (window.lucide) lucide.createIcons();
setTimeout(() => {
document.addEventListener("click", function closeMenu() {
menu.remove();
document.removeEventListener("click", closeMenu);
}, { once: true });
}, 10);
};
window.switchTenantFromMenu = (tenant) => {
document.getElementById("tenantMenu")?.remove();
switchTenant(tenant);
};
function updateTenantLabel() {
const label = document.querySelector("#currentTenantLabel");
if (label) {
label.textContent = state.tenant.replace("·无界", "") || "工作台";
label.title = state.tenant;
}
}
window.toggleUserMenu = (user) => { window.toggleUserMenu = (user) => {
let menu = document.getElementById("userMenu"); let menu = document.getElementById("userMenu");
if (menu) { menu.remove(); return; } if (menu) { menu.remove(); return; }
@@ -197,13 +241,13 @@ function renderProjects() {
document.querySelector("#projects").innerHTML = /*html*/` document.querySelector("#projects").innerHTML = /*html*/`
<div class="grid grid-cols-5 gap-3 mb-4"> <div class="grid grid-cols-5 gap-3 mb-4">
${[ ${[
["项目总数", items.length], ["项目总数", items.length, "folder"],
["任务总数", taskStats.total], ["任务总数", taskStats.total, "list-checks"],
["进行中", taskStats.ongoing], ["进行中", taskStats.ongoing, "play-circle"],
["已结束", taskStats.done], ["已结束", taskStats.done, "check-circle"],
["未开始", taskStats.pending], ["未开始", taskStats.pending, "circle"],
].map(([label, value]) => ` ].map(([label, value, icon]) => `
<div class="bg-white rounded-lg border border-slate-200 p-3 text-center"><p class="text-xs text-slate-500">${label}</p><p class="text-xl font-bold text-slate-800">${value}</p></div> <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>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></div>
`).join("")} `).join("")}
</div> </div>
<div class="flex justify-between items-center mb-3"> <div class="flex justify-between items-center mb-3">

View File

@@ -123,11 +123,23 @@ async function load() {
function switchTab(tab) { function switchTab(tab) {
state.active = tab; state.active = tab;
localStorage.setItem("opc-active-tab", tab); localStorage.setItem("opc-active-tab", tab);
document.querySelectorAll("#tabs button").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === tab)); document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === tab));
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab)); document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab));
render(); render();
} }
function updateSidebarTabs() {
const isOverview = state.tenant === "总工作台";
document.querySelectorAll(".sidebar-tab").forEach((btn) => {
const tab = btn.dataset.tab;
if (isOverview && tab !== "home") {
btn.style.display = "none";
} else {
btn.style.display = "";
}
});
}
function render() { function render() {
if (!state.data) return; if (!state.data) return;
renderHome(); renderHome();
@@ -179,7 +191,10 @@ window.switchTenant = (tenant) => {
state.selectedProject = null; state.selectedProject = null;
localStorage.setItem("opc-active-tenant", tenant); localStorage.setItem("opc-active-tenant", tenant);
document.querySelector("#workspaceTitle").textContent = tenant.replace("·无界", "") + " OPC 工作台"; document.querySelector("#workspaceTitle").textContent = tenant.replace("·无界", "") + " OPC 工作台";
document.querySelectorAll(".workspace-nav-item").forEach((el) => el.classList.toggle("active", el.dataset.tenant === tenant)); const label = document.querySelector("#currentTenantLabel");
if (label) { label.textContent = tenant.replace("·无界", "") || "工作台"; label.title = tenant; }
updateSidebarTabs();
if (tenant === "总工作台") switchTab("home");
load(); load();
}; };
window.doLogout = async () => { window.doLogout = async () => {

View File

@@ -32,25 +32,30 @@ body {
background: rgba(96,165,250,0.15); background: rgba(96,165,250,0.15);
} }
.tabs { .sidebar-tab {
display: flex; display: flex;
gap: 4px; flex-direction: column;
}
.tabs button {
align-items: center; align-items: center;
border-bottom: 2px solid transparent; padding: 8px 4px;
color: #64748b; border-radius: 8px;
display: inline-flex; cursor: pointer;
font-size: 14px; color: #94a3b8;
font-weight: 600; transition: all 0.15s ease;
gap: 8px; width: 100%;
padding: 14px 16px;
} }
.tabs button.active { .sidebar-tab:hover {
border-bottom-color: #1d4ed8; background: #1e293b;
color: #1d4ed8; color: #cbd5e1;
}
.sidebar-tab.active {
background: #1e293b;
color: #60a5fa;
}
.sidebar-tab.active i {
color: #60a5fa;
} }
.panel { .panel {
@@ -677,6 +682,27 @@ textarea { min-height: 80px; height: auto; resize: vertical; }
height: 32px; height: 32px;
} }
/* 产品表格内联日期选择器 */
.prod-date-input {
border: 1px solid transparent;
background: transparent;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
width: 110px;
cursor: pointer;
color: #64748b;
}
.prod-date-input:hover {
border-color: #cbd5e1;
background: #f8fafc;
}
.prod-date-input:focus {
border-color: #3b82f6;
background: white;
outline: none;
}
table { table {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;

View File

@@ -29,29 +29,42 @@
<div class="flex min-h-screen"> <div class="flex min-h-screen">
<!-- 左侧工作台切换栏 --> <!-- 左侧工作台切换栏 -->
<aside class="w-[72px] bg-slate-900 flex flex-col items-center py-6 gap-1 shrink-0 sticky top-0 h-screen overflow-y-auto" id="workspaceSidebar"> <aside class="w-[72px] bg-slate-900 flex flex-col items-center py-6 gap-1 shrink-0 sticky top-0 h-screen overflow-y-auto" id="workspaceSidebar">
<div class="flex flex-col items-center mb-6 cursor-pointer" onclick="document.getElementById('userAvatar').click()"> <div class="flex flex-col items-center mb-2 cursor-pointer" onclick="document.getElementById('userAvatar').click()">
<div class="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center text-white font-bold text-sm hover:ring-2 hover:ring-blue-400 transition-all" id="userAvatar" title=""></div> <div class="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center text-white font-bold text-sm hover:ring-2 hover:ring-blue-400 transition-all" id="userAvatar" title=""></div>
<span class="text-[10px] text-slate-400 mt-1.5 max-w-[64px] truncate text-center" id="userDisplayName" title=""></span> <span class="text-[10px] text-slate-400 mt-1.5 max-w-[64px] truncate text-center" id="userDisplayName" title=""></span>
</div> </div>
<div class="workspace-nav-item active" data-tenant="科普·无界" onclick="switchTenant('科普·无界')" title="科普·无界"> <!-- 分隔线 -->
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg> <div class="w-8 h-px bg-slate-700 my-2"></div>
<span class="text-[10px] mt-1">科普</span> <!-- 工作台切换按钮 -->
<div class="flex flex-col items-center cursor-pointer hover:bg-slate-800 rounded-lg py-2 px-1 w-14 transition-colors" onclick="toggleTenantMenu(event)">
<i data-lucide="layout-grid" style="width:18px;height:18px;color:#94a3b8"></i>
<span class="text-[10px] text-slate-400 mt-1 max-w-[56px] truncate text-center" id="currentTenantLabel">工作台</span>
<i data-lucide="chevron-down" style="width:12px;height:12px;color:#64748b;margin-top:2px"></i>
</div> </div>
<div class="workspace-nav-item" data-tenant="科研·无界" onclick="switchTenant('科研·无界')" title="科研·无界"> <!-- 分隔线 -->
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> <div class="w-8 h-px bg-slate-700 my-2"></div>
<span class="text-[10px] mt-1">科研</span> <!-- 导航 Tab 图标 -->
</div> <div class="flex flex-col items-center gap-1 w-14" id="sidebarTabs">
<div class="workspace-nav-item" data-tenant="医患·无界" onclick="switchTenant('医患·无界')" title="医患·无界"> <div class="sidebar-tab active" data-tab="home" onclick="switchTab('home')" title="首页">
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg> <i data-lucide="home" style="width:20px;height:20px"></i>
<span class="text-[10px] mt-1">医患</span> <span class="text-[10px] mt-1">首页</span>
</div> </div>
<div class="workspace-nav-item" data-tenant="MCN·无界" onclick="switchTenant('MCN·无界')" title="MCN·无界"> <div class="sidebar-tab" data-tab="finance" onclick="switchTab('finance')" title="经营管理">
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="15" rx="2"/><path d="M17 2l-5 5-5-5"/></svg> <i data-lucide="briefcase-business" style="width:20px;height:20px"></i>
<span class="text-[10px] mt-1">MCN</span> <span class="text-[10px] mt-1">财务</span>
</div> </div>
<div class="workspace-nav-item" data-tenant="无界·无界" onclick="switchTenant('无界·无界')" title="无界·无界"> <div class="sidebar-tab" data-tab="projects" onclick="switchTab('projects')" title="重点工作与台账">
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg> <i data-lucide="file-text" style="width:20px;height:20px"></i>
<span class="text-[10px] mt-1">无界</span> <span class="text-[10px] mt-1">台账</span>
</div>
<div class="sidebar-tab" data-tab="proposals" onclick="switchTab('proposals')" title="业务方案">
<i data-lucide="package" style="width:20px;height:20px"></i>
<span class="text-[10px] mt-1">方案</span>
</div>
<div class="sidebar-tab" data-tab="products" onclick="switchTab('products')" title="产品迭代">
<i data-lucide="wallet-cards" style="width:20px;height:20px"></i>
<span class="text-[10px] mt-1">产品</span>
</div>
</div> </div>
</aside> </aside>
<!-- 主内容区 --> <!-- 主内容区 -->
@@ -67,14 +80,6 @@
</div> </div>
</header> </header>
<nav class="tabs border-b border-slate-200 bg-white px-8" id="tabs">
<button class="active" data-tab="home"><i data-lucide="home"></i>首页</button>
<button data-tab="finance"><i data-lucide="briefcase-business"></i>经营管理</button>
<button data-tab="projects"><i data-lucide="file-text"></i>重点工作与台账</button>
<button data-tab="proposals"><i data-lucide="package"></i>业务方案</button>
<button data-tab="products"><i data-lucide="wallet-cards"></i>产品迭代</button>
</nav>
<main class="px-8 py-6"> <main class="px-8 py-6">
<section id="home" class="panel active"></section> <section id="home" class="panel active"></section>
<section id="projects" class="panel"></section> <section id="projects" class="panel"></section>
@@ -86,30 +91,6 @@
</div><!-- 关闭 flex 容器 --> </div><!-- 关闭 flex 容器 -->
<aside id="drawer" class="drawer" aria-hidden="true"></aside> <aside id="drawer" class="drawer" aria-hidden="true"></aside>
<div id="taskModal" class="task-modal"></div> <div id="taskModal" class="task-modal"></div>
<div id="transferModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeTransferModal()">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4" onclick="event.stopPropagation()">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<h3 class="text-lg font-semibold text-slate-800">跨工作台转移</h3>
<button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeTransferModal()"><i data-lucide="x"></i></button>
</div>
<form onsubmit="submitTransfer(event)" class="p-6 grid gap-4">
<input type="hidden" name="transfer_resource" id="transfer-resource" value="">
<input type="hidden" name="transfer_id" id="transfer-id" value="">
<p id="transfer-title-text" class="text-sm text-slate-600"></p>
<label class="block"><span class="text-xs font-medium text-slate-500">目标工作台</span>
<select name="transfer_tenant" class="form-ctrl mt-1">
<option value="科普·无界">科普·无界</option>
<option value="科研·无界">科研·无界</option>
<option value="医患·无界">医患·无界</option>
</select>
</label>
<div class="flex justify-end gap-3 pt-3">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeTransferModal()">取消</button>
<button type="submit" class="btn btn-primary btn-sm">确认转移</button>
</div>
</form>
</div>
</div>
<!-- 新增项目模态框 --> <!-- 新增项目模态框 -->
<div id="newProjectModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeNewProjectModal()"> <div id="newProjectModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeNewProjectModal()">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onclick="event.stopPropagation()"> <div class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onclick="event.stopPropagation()">

View File

@@ -52,7 +52,7 @@
</div> </div>
</div> </div>
<button class="btn" id="loginBtn" onclick="doLogin()">登 录</button> <button class="btn" id="loginBtn" onclick="doLogin()">登 录</button>
<div class="footer">默认管理员:<strong>qiukai / yxcowork2026</strong></div> <div class="footer">Powered by <strong>yxcowork.vip</strong></div>
</div> </div>
<script> <script>
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();