Compare commits

..

18 Commits

Author SHA1 Message Date
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
21 changed files with 926 additions and 157 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 ==="
find "${DEPLOY_BASE}/releases" -mindepth 1 -maxdepth 1 -type d | sort | head -n -5 | 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]
@@ -487,7 +493,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 +691,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 +728,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
@@ -777,22 +783,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 +836,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),
@@ -1029,11 +1052,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,28 @@
"""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
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_seed_users()
migrate_seed_demo_data()

View File

@@ -0,0 +1,61 @@
"""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", "platform",
"ALTER TABLE product_versions ADD COLUMN platform VARCHAR(100) 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")
conn.commit()
print("[migrate] 加列迁移完成")
finally:
conn.close()

View File

@@ -0,0 +1,49 @@
"""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()

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,26 +1,13 @@
// 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");

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 || "");
@@ -37,7 +37,7 @@ function openDrawer(resource, id) {
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">
<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">
@@ -172,33 +172,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) => {

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 || [];
@@ -102,12 +104,12 @@ function renderFinance() {
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-6 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"],["流程项目","" + inContract.length,"file-clock"],["流程金额",moneyInt(sumContract),"clock"],["待签项目","" + 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 !== '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><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>
@@ -166,7 +168,7 @@ function renderFinance() {
<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")} ${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><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")}
</div>`; </div>`;
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
} }

View File

@@ -3,22 +3,33 @@
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"> <div class="grid grid-cols-4 gap-3">
@@ -29,13 +40,13 @@ function renderHome() {
["产品迭代", 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

@@ -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,7 +123,7 @@ 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();
} }
@@ -179,7 +179,8 @@ 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; }
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 {

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();