Compare commits

...

30 Commits

Author SHA1 Message Date
mac
5b1dc4555f v1.0.1-beta: MySQL迁移 + 用户体系 + 经营管理/任务/产品改版 2026-06-22 19:34:31 +08:00
mac
353f11663c v3.3.0 — 左侧增加工作台切换侧边栏(科普/科研/医患) 2026-06-17 15:41:02 +08:00
mac
f3cf6902dd v3.2.1 — 去掉右上角刷新按钮 2026-06-17 15:39:27 +08:00
mac
25f3b9fe0d v3.2.0 — 财务项目增加总确收/总毛利列+弹窗计算显示 2026-06-17 15:36:41 +08:00
mac
cf08b2d241 v3.1.5 — 移除财务页面内嵌的旧表单,仅保留弹窗 2026-06-17 15:29:27 +08:00
mac
4911f24d40 v3.1.4 — 修复pfs变量丢失导致加载失败 2026-06-17 13:13:27 +08:00
mac
8c24abd53e v3.1.3 — 财务弹窗UI优化:分区卡片布局+月网格+圆角+更大尺寸 2026-06-17 13:12:39 +08:00
mac
0a7f70757d v3.1.2 — 财务弹窗按工作台显示项目和业务类型 2026-06-17 13:11:06 +08:00
mac
29dc7e040e v3.1.1 — 科普·无界新增"全品类科普"类型 2026-06-17 13:07:36 +08:00
mac
c8387011cc v3.1.0 — 财务分类重构:三大工作台13种业务类型+66条重新分配 2026-06-17 13:05:58 +08:00
mac
5061de70f8 v3.0.4 — 点击项目行弹出编辑弹窗+PUT保存 2026-06-17 11:27:29 +08:00
mac
ea3ba25da5 v3.0.3 — 修复:新增财务项目改为弹窗按钮+finFilter+createFinance修正 2026-06-17 11:17:12 +08:00
mac
bd7125fab8 v3.0.2 — 新增财务项目改为弹窗按钮+月度趋势缩减为6个月 2026-06-16 17:11:02 +08:00
mac
94dd1fe677 v3.0.1 — 财务项目明细已签/待签分Tab展示 2026-06-16 16:57:13 +08:00
mac
fa6c9b1711 v3.0 — 财务重构为项目财务视图:汇总卡片+项目明细+月度确收/毛利 2026-06-16 16:43:44 +08:00
mac
f4eacfafe2 v2.0.9 — 业务方案删除按钮改为通用deleteDrawerItem支持所有资源 2026-06-16 16:03:33 +08:00
mac
f8c816dc38 v2.0.8 — 任务行添加拖拽手柄图标(grip-vertical) 2026-06-16 16:01:54 +08:00
mac
e2d9049e45 v2.0.7 — 标题随项目切换动态变化:科普/科研/医患 OPC 工作台 2026-06-16 15:57:18 +08:00
mac
1b0049e342 v2.0.6 — X轴12月(前9+当前+后2) + Y轴万元 + 净利口径=确认收入-人力-费用-采购 2026-06-16 15:56:21 +08:00
mac
87a5d4f81d v2.0.5 — 财务曲线改为5类对应+月度净利=确认收入-人力成本-费用-外部采购 2026-06-16 15:54:24 +08:00
mac
d6ec7b24ec v2.0.4 — 曲线图默认折叠可展开,明细列表默认展示 2026-06-16 15:51:09 +08:00
mac
194c91cf25 v2.0.3 — 财务增加费用说明输入框 + 明细表默认折叠可展开 2026-06-16 15:49:35 +08:00
mac
68797e4fb5 v2.0.2 — 财务类型改为5类:签单/确认收入/人力成本/费用/外部采购 2026-06-16 15:47:44 +08:00
mac
af4ae1cbc3 v2.0.1 — 财务表单简化:日月合并+去类型+分类含签单+日历控件 2026-06-16 15:47:04 +08:00
mac
c42abb05da v2.0 — 多项目支持:右上角下拉切换科普/科研/医患三个项目 2026-06-16 15:42:28 +08:00
mac
4d1dc3b355 v1.8.1 — 新增项目表单默认展开 + 去掉按钮 + 按钮文字改为新增项目 2026-06-16 15:15:52 +08:00
mac
60bae583b2 v1.8.0 — 任务checkbox+删除线 + 拖拽排序 + 抽屉删除按钮 2026-06-16 15:14:31 +08:00
mac
c68fcaadcc v1.7.10 — 首页指标简化为6卡片:重点项目/业务方案/产品版本/本月确收/本月毛利/本月净利 2026-06-16 15:09:49 +08:00
mac
2c199aae76 v1.7.9 — 修复首页丢失 m 变量定义 2026-06-16 15:05:11 +08:00
mac
be6a7f5c38 v1.7.8 — 首页改为3表格卡片(合同/确收/毛利) + 合同时间维指标 2026-06-16 14:21:57 +08:00
7 changed files with 2067 additions and 347 deletions

View File

@@ -1,5 +1,14 @@
# OPC Manager Version Log # OPC Manager Version Log
## v1.0.1-beta — 2026-06-22
- 数据库迁移SQLite → MySQL 9.6,适配占位符/类型/游标
- 用户体系:管理员 + OPC负责人角色工作台权限隔离登录鉴权
- 经营管理:状态改为已签约/流程中/待签约三类,签约月份列可编辑,财务指标卡片(确收/毛利/回款/费用/现金流)
- 重点工作与台账:阶段排序固定化+折叠分组,任务拖拽手柄与状态互换位置,关闭抽屉自动刷新,首次进入自动选第一个项目
- 产品迭代统一表格去平台tab状态点击循环切换日期改为 date 选择器,新增版本改用右侧抽屉
- 左侧工作台/顶部 tab 记忆恢复,跨工作台转移功能
- 近期动态修复 tenant 归属
## v1.2.0 — 2026-06-15 ## v1.2.0 — 2026-06-15
- 业务机会 + 运营管理合并为「重点项目」Tab统一表格展示 - 业务机会 + 运营管理合并为「重点项目」Tab统一表格展示
- 新增项目任务追踪:按阶段分组展示里程碑/执行项/负责人/截止日/卡点 - 新增项目任务追踪:按阶段分组展示里程碑/执行项/负责人/截止日/卡点

View File

@@ -2,9 +2,11 @@ from datetime import date, datetime
from pathlib import Path from pathlib import Path
import os import os
import shutil import shutil
import sqlite3 import sqlite3 # 保留用于数据迁移
import mysql.connector
from flask import Flask, jsonify, render_template, request, send_file from flask import Flask, jsonify, render_template, request, send_file, session, redirect
from werkzeug.security import generate_password_hash, check_password_hash
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -21,145 +23,323 @@ app = Flask(
template_folder=str(ROOT / "templates"), template_folder=str(ROOT / "templates"),
static_folder=str(ROOT / "static"), static_folder=str(ROOT / "static"),
) )
app.secret_key = os.environ.get("SECRET_KEY", "opc-dev-secret-2026")
# ---------- 鉴权 ----------
def login_required(f):
from functools import wraps
@wraps(f)
def decorated(*args, **kwargs):
if "user_id" not in session:
return jsonify({"error": "未登录"}), 401
return f(*args, **kwargs)
return decorated
@app.route("/login")
def login_page():
return render_template("login.html")
@app.route("/api/auth/login", methods=["POST"])
def auth_login():
data = request.get_json(force=True) or {}
username = data.get("username", "").strip()
password = data.get("password", "")
conn = db()
try:
user = one(conn, "SELECT * FROM users WHERE username=?", (username,))
if not user or not check_password_hash(user["password_hash"], password):
return jsonify({"error": "用户名或密码错误"}), 401
session["user_id"] = user["id"]
session["username"] = user["username"]
session["display_name"] = user["display_name"]
session["role"] = user["role"]
# 管理员可看所有工作台OPC负责人看分配的工作台
if user["role"] == "admin":
session["tenants"] = ["科普·无界", "科研·无界", "医患·无界"]
else:
ut = rows(conn, "SELECT tenant FROM user_tenants WHERE user_id=?", (user["id"],))
session["tenants"] = [x["tenant"] for x in ut]
return jsonify({
"ok": True,
"user": {"id": user["id"], "username": user["username"], "display_name": user["display_name"], "role": user["role"]},
"tenants": session["tenants"],
})
finally:
conn.close()
@app.route("/api/auth/logout", methods=["POST"])
def auth_logout():
session.clear()
return jsonify({"ok": True})
@app.route("/api/auth/me")
def auth_me():
if "user_id" not in session:
return jsonify({"logged_in": False})
return jsonify({
"logged_in": True,
"user": {"id": session["user_id"], "username": session["username"], "display_name": session["display_name"], "role": session["role"]},
"tenants": session.get("tenants", []),
})
# ---------- 业务 API ----------
def db(): def db():
conn = sqlite3.connect(DB_PATH) return mysql.connector.connect(
conn.row_factory = sqlite3.Row host="127.0.0.1",
return conn port=3306,
user="opc",
password="opc123456",
database="opc",
charset="utf8mb4",
collation="utf8mb4_unicode_ci",
)
def now(): def now():
return datetime.utcnow().isoformat() return datetime.utcnow().isoformat()
def _exec(conn, sql, args=()):
"""执行 SQL自动将 ? 转为 MySQL 的 %s"""
cur = conn.cursor(dictionary=True)
cur.execute(sql.replace("?", "%s"), args)
return cur
def rows(conn, sql, args=()): def rows(conn, sql, args=()):
return [dict(row) for row in conn.execute(sql, args).fetchall()] cur = _exec(conn, sql, args)
rows = cur.fetchall()
cur.close()
return rows
def one(conn, sql, args=()): def one(conn, sql, args=()):
row = conn.execute(sql, args).fetchone() cur = _exec(conn, sql, args)
return dict(row) if row else None row = cur.fetchone()
cur.close()
return row
def init_db(): def init_db():
conn = db() conn = db()
conn.executescript( _exec(conn, """CREATE TABLE IF NOT EXISTS sales_leads (
""" id INT AUTO_INCREMENT PRIMARY KEY,
CREATE TABLE IF NOT EXISTS sales_leads ( target_customer VARCHAR(1000) NOT NULL,
id INTEGER PRIMARY KEY AUTOINCREMENT, priority VARCHAR(1000) NOT NULL DEFAULT 'P1',
target_customer TEXT NOT NULL, status VARCHAR(1000) NOT NULL DEFAULT '待跟进',
priority TEXT NOT NULL DEFAULT 'P1', created_at VARCHAR(30) NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '待跟进', updated_at VARCHAR(30) NOT NULL DEFAULT ''
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, )""")
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP conn.commit()
);
CREATE TABLE IF NOT EXISTS follow_up_records ( _exec(conn, """CREATE TABLE IF NOT EXISTS follow_up_records (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INT AUTO_INCREMENT PRIMARY KEY,
target_type TEXT NOT NULL, target_type VARCHAR(1000) NOT NULL,
target_id INTEGER NOT NULL, target_id INT NOT NULL,
followed_at TEXT NOT NULL DEFAULT '', followed_at VARCHAR(1000) NOT NULL DEFAULT '',
follower TEXT NOT NULL DEFAULT '慰心', follower VARCHAR(1000) NOT NULL DEFAULT '慰心',
follow_up_method TEXT NOT NULL DEFAULT '记录', follow_up_method VARCHAR(1000) NOT NULL DEFAULT '记录',
content TEXT NOT NULL DEFAULT '', content VARCHAR(1000) NOT NULL DEFAULT '',
next_action TEXT NOT NULL DEFAULT '', next_action VARCHAR(1000) NOT NULL DEFAULT '',
next_follow_up_at TEXT NOT NULL DEFAULT '', next_follow_up_at VARCHAR(1000) NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at VARCHAR(30) NOT NULL DEFAULT ''
); )""")
CREATE TABLE IF NOT EXISTS business_proposals ( conn.commit()
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_or_project_name TEXT NOT NULL, _exec(conn, """CREATE TABLE IF NOT EXISTS business_proposals (
version TEXT NOT NULL, id INT AUTO_INCREMENT PRIMARY KEY,
description TEXT NOT NULL DEFAULT '', customer_or_project_name VARCHAR(1000) NOT NULL,
status TEXT NOT NULL DEFAULT '草稿', version VARCHAR(1000) NOT NULL,
created_date TEXT NOT NULL DEFAULT '', description VARCHAR(1000) NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, status VARCHAR(1000) NOT NULL DEFAULT '草稿',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_date VARCHAR(1000) NOT NULL DEFAULT '',
); created_at VARCHAR(30) NOT NULL DEFAULT '',
CREATE TABLE IF NOT EXISTS operation_projects ( updated_at VARCHAR(30) NOT NULL DEFAULT ''
id INTEGER PRIMARY KEY AUTOINCREMENT, )""")
project_name TEXT NOT NULL, conn.commit()
project_version TEXT NOT NULL DEFAULT 'v1.0',
project_type TEXT NOT NULL DEFAULT 'opportunity', _exec(conn, """CREATE TABLE IF NOT EXISTS operation_projects (
project_status TEXT NOT NULL DEFAULT '', id INT AUTO_INCREMENT PRIMARY KEY,
current_stage TEXT NOT NULL DEFAULT '', project_name VARCHAR(1000) NOT NULL,
owner TEXT NOT NULL DEFAULT '慰心', project_version VARCHAR(1000) NOT NULL DEFAULT 'v1.0',
start_date TEXT NOT NULL DEFAULT '', project_type VARCHAR(1000) NOT NULL DEFAULT 'opportunity',
end_date TEXT NOT NULL DEFAULT '', project_status VARCHAR(1000) NOT NULL DEFAULT '',
target_customer TEXT NOT NULL DEFAULT '', current_stage VARCHAR(1000) NOT NULL DEFAULT '',
customer_need TEXT NOT NULL DEFAULT '', owner VARCHAR(1000) NOT NULL DEFAULT '慰心',
expected_contract_amount REAL NOT NULL DEFAULT 0, start_date VARCHAR(1000) NOT NULL DEFAULT '',
expected_sign_date TEXT NOT NULL DEFAULT '', end_date VARCHAR(1000) NOT NULL DEFAULT '',
sign_probability REAL NOT NULL DEFAULT 0, target_customer VARCHAR(1000) NOT NULL DEFAULT '',
next_action TEXT 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, related_business_proposal_id INTEGER,
sop_file_id INTEGER, sop_file_id INTEGER,
sop_stage TEXT NOT NULL DEFAULT '', sop_stage VARCHAR(1000) NOT NULL DEFAULT '',
execution_progress REAL NOT NULL DEFAULT 0, execution_progress DOUBLE NOT NULL DEFAULT 0,
current_deliverable TEXT NOT NULL DEFAULT '', current_deliverable VARCHAR(1000) NOT NULL DEFAULT '',
risks TEXT NOT NULL DEFAULT '', risks VARCHAR(1000) NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '', notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at VARCHAR(30) NOT NULL DEFAULT ''
); )""")
CREATE TABLE IF NOT EXISTS product_versions ( conn.commit()
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_name TEXT NOT NULL, _exec(conn, """CREATE TABLE IF NOT EXISTS product_versions (
version TEXT NOT NULL, id INT AUTO_INCREMENT PRIMARY KEY,
version_goal TEXT NOT NULL DEFAULT '', product_name VARCHAR(1000) NOT NULL,
feature_list TEXT NOT NULL DEFAULT '', version VARCHAR(1000) NOT NULL,
launch_date TEXT NOT NULL DEFAULT '', version_goal VARCHAR(1000) NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '规划中', feature_list VARCHAR(1000) NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '', launch_date VARCHAR(1000) NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, status VARCHAR(1000) NOT NULL DEFAULT '规划中',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP notes VARCHAR(1000) NOT NULL DEFAULT '',
); created_at VARCHAR(30) NOT NULL DEFAULT '',
CREATE TABLE IF NOT EXISTS finance_records ( updated_at VARCHAR(30) NOT NULL DEFAULT ''
id INTEGER PRIMARY KEY AUTOINCREMENT, )""")
month TEXT NOT NULL, conn.commit()
project_name TEXT NOT NULL DEFAULT '科普(慰心斋)',
record_type TEXT NOT NULL, _exec(conn, """CREATE TABLE IF NOT EXISTS finance_records (
category TEXT NOT NULL DEFAULT '', id INT AUTO_INCREMENT PRIMARY KEY,
amount REAL NOT NULL DEFAULT 0, month VARCHAR(1000) NOT NULL,
occurred_date TEXT NOT NULL DEFAULT '', project_name VARCHAR(1000) NOT NULL DEFAULT '科普(慰心斋)',
notes TEXT NOT NULL DEFAULT '', record_type VARCHAR(1000) NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, category VARCHAR(1000) NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP amount DOUBLE NOT NULL DEFAULT 0,
); occurred_date VARCHAR(1000) NOT NULL DEFAULT '',
CREATE TABLE IF NOT EXISTS file_assets ( notes VARCHAR(1000) NOT NULL DEFAULT '',
id INTEGER PRIMARY KEY AUTOINCREMENT, created_at VARCHAR(30) NOT NULL DEFAULT '',
module TEXT NOT NULL, updated_at VARCHAR(30) NOT NULL DEFAULT ''
owner_id INTEGER NOT NULL, )""")
owner_version TEXT NOT NULL DEFAULT '', conn.commit()
file_category TEXT NOT NULL DEFAULT '',
file_name TEXT NOT NULL, _exec(conn, """CREATE TABLE IF NOT EXISTS file_assets (
file_type TEXT NOT NULL DEFAULT '', 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_size INTEGER NOT NULL DEFAULT 0,
file_path TEXT NOT NULL, file_path VARCHAR(1000) NOT NULL,
is_external INTEGER NOT NULL DEFAULT 0, is_external INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '', notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at VARCHAR(30) NOT NULL DEFAULT ''
); )""")
CREATE TABLE IF NOT EXISTS project_tasks ( conn.commit()
id INTEGER PRIMARY KEY AUTOINCREMENT,
_exec(conn, """CREATE TABLE IF NOT EXISTS project_tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INTEGER NOT NULL, project_id INTEGER NOT NULL,
phase TEXT NOT NULL DEFAULT '', phase VARCHAR(1000) NOT NULL DEFAULT '',
milestone TEXT NOT NULL DEFAULT '', milestone VARCHAR(1000) NOT NULL DEFAULT '',
task TEXT NOT NULL DEFAULT '', task VARCHAR(1000) NOT NULL DEFAULT '',
owner TEXT NOT NULL DEFAULT '', owner VARCHAR(1000) NOT NULL DEFAULT '',
due_date TEXT NOT NULL DEFAULT '', due_date VARCHAR(1000) NOT NULL DEFAULT '',
blockers TEXT NOT NULL DEFAULT '', blockers VARCHAR(1000) NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '', notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at VARCHAR(30) NOT NULL DEFAULT ''
); )""")
""" conn.commit()
)
# Schema migrations # 用户表
try: conn.execute("ALTER TABLE product_versions ADD COLUMN platform TEXT NOT NULL DEFAULT ''") try: _exec(conn, """CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'opc_owner',
created_at VARCHAR(30) NOT NULL DEFAULT ''
)""")
except: pass except: pass
conn.commit()
# 用户-工作台关联表
try: _exec(conn, """CREATE TABLE IF NOT EXISTS user_tenants (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
tenant VARCHAR(100) NOT NULL,
UNIQUE KEY (user_id, tenant)
)""")
except: pass
conn.commit()
# project_finances 表(月度预算 + 签约信息)
try: _exec(conn, """CREATE TABLE IF NOT EXISTS project_finances (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界',
project_id VARCHAR(100) NOT NULL DEFAULT '',
business_type VARCHAR(100) NOT NULL DEFAULT '',
customer_name VARCHAR(200) NOT NULL DEFAULT '',
sign_amount DOUBLE NOT NULL DEFAULT 0,
sign_month VARCHAR(20) NOT NULL DEFAULT '',
status VARCHAR(50) NOT NULL DEFAULT '待签约',
sales_person VARCHAR(100) NOT NULL DEFAULT '',
total_rev DOUBLE NOT NULL DEFAULT 0,
total_gross DOUBLE NOT NULL DEFAULT 0,
budget_data TEXT,
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""")
except: pass
conn.commit()
# Schema migrations — 添加后续迁移的列(幂等)
migrations = [
"ALTER TABLE sales_leads ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE follow_up_records ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE business_proposals ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE business_proposals ADD COLUMN proposal_type VARCHAR(100) NOT NULL DEFAULT '业务方案'",
"ALTER TABLE business_proposals ADD COLUMN notes VARCHAR(2000) NOT NULL DEFAULT ''",
"ALTER TABLE operation_projects ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE product_versions ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE product_versions ADD COLUMN platform VARCHAR(100) NOT NULL DEFAULT ''",
"ALTER TABLE finance_records ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE project_tasks ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'",
"ALTER TABLE project_tasks ADD COLUMN status VARCHAR(50) NOT NULL DEFAULT '未开始'",
"ALTER TABLE project_tasks ADD COLUMN sort_order INT NOT NULL DEFAULT 0",
"ALTER TABLE project_tasks ADD COLUMN priority VARCHAR(10) NOT NULL DEFAULT 'P2'",
# 12 月字段(确收/毛利/回款/费用/月度现金流)
]
for m in ["01","02","03","04","05","06","07","08","09","10","11","12"]:
migrations.append(f"ALTER TABLE project_finances ADD COLUMN rev_2026_{m} DOUBLE NOT NULL DEFAULT 0")
migrations.append(f"ALTER TABLE project_finances ADD COLUMN gross_2026_{m} DOUBLE NOT NULL DEFAULT 0")
migrations.append(f"ALTER TABLE project_finances ADD COLUMN payment_2026_{m} DOUBLE NOT NULL DEFAULT 0")
migrations.append(f"ALTER TABLE project_finances ADD COLUMN cost_2026_{m} DOUBLE NOT NULL DEFAULT 0")
for mig in migrations:
try: _exec(conn, mig)
except: pass
conn.commit()
# 初始化默认用户(只执行一次)
if not one(conn, "SELECT id FROM users LIMIT 1"):
_exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""",
("qiukai", generate_password_hash("yxcowork2026", "pbkdf2:sha256"), "qiukai", "admin", date.today().isoformat()))
_exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""",
("kepu", generate_password_hash("kepu123", "pbkdf2:sha256"), "科普负责人", "opc_owner", date.today().isoformat()))
_exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""",
("keyan", generate_password_hash("keyan123", "pbkdf2:sha256"), "科研负责人", "opc_owner", date.today().isoformat()))
_exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""",
("yihuan", generate_password_hash("yihuan123", "pbkdf2:sha256"), "医患负责人", "opc_owner", date.today().isoformat()))
# 各 OPC 负责人绑定工作台
for uname, tenant in [("kepu","科普·无界"),("keyan","科研·无界"),("yihuan","医患·无界")]:
u = one(conn, "SELECT id FROM users WHERE username=?", (uname,))
if u:
_exec(conn, "INSERT INTO user_tenants (user_id, tenant) VALUES (?,?)", (u["id"], tenant))
conn.commit()
if one(conn, "SELECT id FROM sales_leads LIMIT 1"): if one(conn, "SELECT id FROM sales_leads LIMIT 1"):
conn.close() conn.close()
@@ -173,16 +353,16 @@ CREATE TABLE IF NOT EXISTS project_tasks (
("天广实生物", "P1", "待跟进", "血液肿瘤医生教育机会。"), ("天广实生物", "P1", "待跟进", "血液肿瘤医生教育机会。"),
] ]
for customer, priority, status, note in sales: for customer, priority, status, note in sales:
cur = conn.execute( cur = _exec(conn,
"INSERT INTO sales_leads (target_customer, priority, status) VALUES (?,?,?)", "INSERT INTO sales_leads (target_customer, priority, status) VALUES (?,?,?)",
(customer, priority, status), (customer, priority, status),
) )
conn.execute( _exec(conn,
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)", "INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("sales", cur.lastrowid, date.today().isoformat(), note, "明确下一次沟通人和时间"), ("sales", cur.lastrowid, date.today().isoformat(), note, "明确下一次沟通人和时间"),
) )
cur = conn.execute( cur = _exec(conn,
"INSERT INTO business_proposals (customer_or_project_name,version,description,status,created_date) VALUES (?,?,?,?,?)", "INSERT INTO business_proposals (customer_or_project_name,version,description,status,created_date) VALUES (?,?,?,?,?)",
("信达生物", "v1.5", "信达科普项目续约与报价方案", "已提交客户", "2026-05-28"), ("信达生物", "v1.5", "信达科普项目续约与报价方案", "已提交客户", "2026-05-28"),
) )
@@ -204,14 +384,14 @@ CREATE TABLE IF NOT EXISTS project_tasks (
] ]
op_dir = WEIXIN_BASE / "3、运营方案" op_dir = WEIXIN_BASE / "3、运营方案"
for name, version, kind, status, stage, progress, note in projects: for name, version, kind, status, stage, progress, note in projects:
cur = conn.execute( cur = _exec(conn,
"""INSERT INTO operation_projects """INSERT INTO operation_projects
(project_name,project_version,project_type,project_status,current_stage,target_customer,customer_need, (project_name,project_version,project_type,project_status,current_stage,target_customer,customer_need,
expected_contract_amount,expected_sign_date,sign_probability,next_action,sop_stage,execution_progress,current_deliverable) expected_contract_amount,expected_sign_date,sign_probability,next_action,sop_stage,execution_progress,current_deliverable)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(name, version, kind, status, stage, "圆心科技", "科普内容项目执行与管理", 0 if kind == "execution" else 200, "2026-06", 100 if kind == "execution" else 70, "补齐版本要求文件并更新下一节点", stage, progress, note), (name, version, kind, status, stage, "圆心科技", "科普内容项目执行与管理", 0 if kind == "execution" else 200, "2026-06", 100 if kind == "execution" else 70, "补齐版本要求文件并更新下一节点", stage, progress, note),
) )
conn.execute( _exec(conn,
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)", "INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("operation", cur.lastrowid, date.today().isoformat(), note, "补齐版本要求文件并更新下一节点"), ("operation", cur.lastrowid, date.today().isoformat(), note, "补齐版本要求文件并更新下一节点"),
) )
@@ -234,11 +414,11 @@ CREATE TABLE IF NOT EXISTS project_tasks (
("渠道分发引擎", "v1.0", "六渠道统一分发", "分发 API、内容适配、分发排期、效果追踪", "2027-Q1", "规划中", "科普平台"), ("渠道分发引擎", "v1.0", "六渠道统一分发", "分发 API、内容适配、分发排期、效果追踪", "2027-Q1", "规划中", "科普平台"),
] ]
for product in products: for product in products:
cur = conn.execute( cur = _exec(conn,
"INSERT INTO product_versions (product_name,version,version_goal,feature_list,launch_date,status,platform) VALUES (?,?,?,?,?,?,?)", "INSERT INTO product_versions (product_name,version,version_goal,feature_list,launch_date,status,platform) VALUES (?,?,?,?,?,?,?)",
product, product,
) )
conn.execute( _exec(conn,
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)", "INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("product", cur.lastrowid, date.today().isoformat(), f"{product[0]} {product[1]}{product[2]}", "按路线图推进"), ("product", cur.lastrowid, date.today().isoformat(), f"{product[0]} {product[1]}{product[2]}", "按路线图推进"),
) )
@@ -250,7 +430,7 @@ CREATE TABLE IF NOT EXISTS project_tasks (
("2026-05", "cost_expense", "运营管理", 16, "项目管理与渠道协同"), ("2026-05", "cost_expense", "运营管理", 16, "项目管理与渠道协同"),
("2026-06", "cost_expense", "渠道分发", 24, "投放与分发费用"), ("2026-06", "cost_expense", "渠道分发", 24, "投放与分发费用"),
]: ]:
conn.execute( _exec(conn,
"INSERT INTO finance_records (month,record_type,category,amount,occurred_date,notes) VALUES (?,?,?,?,?,?)", "INSERT INTO finance_records (month,record_type,category,amount,occurred_date,notes) VALUES (?,?,?,?,?,?)",
(month, record_type, category, amount, f"{month}-01", notes), (month, record_type, category, amount, f"{month}-01", notes),
) )
@@ -269,7 +449,7 @@ CREATE TABLE IF NOT EXISTS project_tasks (
("阶段2系统与标准搭建", "脚本生产及审核", "生产脚本", "军营", "2026-06-12", "脚本目前生产比较机械,需要提前准备", "细分标签领域完成"), ("阶段2系统与标准搭建", "脚本生产及审核", "生产脚本", "军营", "2026-06-12", "脚本目前生产比较机械,需要提前准备", "细分标签领域完成"),
] ]
for phase, milestone, task, owner, due_date, blockers, notes in tasks_seed: for phase, milestone, task, owner, due_date, blockers, notes in tasks_seed:
conn.execute( _exec(conn,
"INSERT INTO project_tasks (project_id,phase,milestone,task,owner,due_date,blockers,notes) VALUES (?,?,?,?,?,?,?,?)", "INSERT INTO project_tasks (project_id,phase,milestone,task,owner,due_date,blockers,notes) VALUES (?,?,?,?,?,?,?,?)",
(1, phase, milestone, task, owner, due_date, blockers, notes), (1, phase, milestone, task, owner, due_date, blockers, notes),
) )
@@ -282,7 +462,7 @@ def add_file_index(conn, module, owner_id, owner_version, category, path, extern
path = Path(path) path = Path(path)
if not path.exists(): if not path.exists():
return return
conn.execute( _exec(conn,
"""INSERT INTO file_assets """INSERT INTO file_assets
(module,owner_id,owner_version,file_category,file_name,file_type,file_size,file_path,is_external) (module,owner_id,owner_version,file_category,file_name,file_type,file_size,file_path,is_external)
VALUES (?,?,?,?,?,?,?,?,?)""", VALUES (?,?,?,?,?,?,?,?,?)""",
@@ -316,44 +496,86 @@ def attach_common(conn, resource, items):
return items return items
def monthly_finance(conn): def monthly_finance(conn, tenant="科普·无界"):
from datetime import date
today = date.today()
# 6 months: 3 before + current + 2 after
from dateutil.relativedelta import relativedelta
start = today + relativedelta(months=-3)
months = []
for i in range(6):
m = start + relativedelta(months=i)
months.append(m.strftime("%Y-%m"))
data = [] data = []
for item in rows(conn, "SELECT DISTINCT month FROM finance_records ORDER BY month"): for month in months:
month = item["month"] col_month = month.replace("-", "_")
revenue = one(conn, "SELECT COALESCE(SUM(amount),0) AS v FROM finance_records WHERE month=? AND record_type='revenue'", (month,))["v"] col_rev = f"rev_{col_month}"
cost = one(conn, "SELECT COALESCE(SUM(amount),0) AS v FROM finance_records WHERE month=? AND record_type='cost_expense'", (month,))["v"] col_gross = f"gross_{col_month}"
data.append({"month": month, "revenue": revenue, "gross_profit": revenue - cost, "cost_expense": cost, "net_profit": revenue - cost}) # Only project_finances has columns for 2026-06 through 2026-09
if month in ["2026-06", "2026-07", "2026-08", "2026-09"]:
revenue = one(conn, f"SELECT COALESCE(SUM({col_rev}),0) AS v FROM project_finances WHERE tenant=?", (tenant,))["v"]
gross = one(conn, f"SELECT COALESCE(SUM({col_gross}),0) AS v FROM project_finances WHERE tenant=?", (tenant,))["v"]
else:
revenue = 0
gross = 0
data.append({
"month": month, "revenue": revenue,
"labor": 0, "expense": 0, "purchase": 0,
"net_profit": gross,
})
return data return data
@app.route("/") @app.route("/")
def index(): def index():
if "user_id" not in session:
return redirect("/login")
return render_template("index.html") return render_template("index.html")
@app.route("/api/bootstrap") @app.route("/api/bootstrap")
def bootstrap(): def bootstrap():
if "user_id" not in session:
return jsonify({"error": "未登录"}), 401
tenant = request.args.get("tenant", session.get("tenants", ["科普·无界"])[0])
# 验证用户是否有权限访问该 workbench
allowed = session.get("tenants", [])
if tenant not in allowed:
tenant = allowed[0]
conn = db() conn = db()
try: try:
sales = attach_common(conn, "sales", rows(conn, "SELECT * FROM sales_leads ORDER BY id DESC")) def q(sql, *args):
proposals = attach_common(conn, "proposals", rows(conn, "SELECT * FROM business_proposals ORDER BY id DESC")) return rows(conn, sql, args)
operations = attach_common(conn, "operations", rows(conn, "SELECT * FROM operation_projects ORDER BY id DESC")) sales = attach_common(conn, "sales", q("SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", tenant))
products = attach_common(conn, "products", rows(conn, "SELECT * FROM product_versions ORDER BY id DESC")) proposals = attach_common(conn, "proposals", q("SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", tenant))
finance = rows(conn, "SELECT * FROM finance_records ORDER BY month DESC, id DESC") operations = attach_common(conn, "operations", q("SELECT * FROM operation_projects WHERE tenant=? ORDER BY id DESC", tenant))
products = attach_common(conn, "products", q("SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", tenant))
finance = q("SELECT * FROM finance_records WHERE tenant=? ORDER BY month DESC, id DESC", tenant)
tasks = q("SELECT * FROM project_tasks WHERE tenant=? ORDER BY phase, sort_order, id", tenant)
pfs = q("SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", tenant)
current_month = "2026-06" current_month = "2026-06"
# Finance aggregates # Finance aggregates — from project_finances (project-based)
def sum_finance(months, rtype): def pf_sum(field):
return sum(x["amount"] for x in finance if x["month"] in months and x["record_type"] == rtype) return sum(x[field] or 0 for x in pfs)
months_2026 = [f"2026-{m:02d}" for m in range(1,7)] rev_month = pf_sum("rev_2026_06")
months_q2 = ["2026-04","2026-05","2026-06"] gross_month = pf_sum("gross_2026_06")
revenue_annual = sum_finance(months_2026, "revenue") rev_q2 = pf_sum("rev_2026_06")
cost_annual = sum_finance(months_2026, "cost_expense") gross_q2 = pf_sum("gross_2026_06")
revenue_q2 = sum_finance(months_q2, "revenue") rev_annual = rev_q2
cost_q2 = sum_finance(months_q2, "cost_expense") gross_annual = gross_q2
revenue_month = sum_finance([current_month], "revenue") # Contract aggregates — time-based
cost_month = sum_finance([current_month], "cost_expense")
# Contract aggregates
signed_amount = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_status"] == "已签约") signed_amount = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_status"] == "已签约")
from datetime import date
today = date.today()
def contract_in_period(op, start, end):
if op["project_status"] != "已签约": return False
try:
d = date.fromisoformat(op["created_at"][:10])
return start <= d <= end
except: return False
signed_annual = sum(x["expected_contract_amount"] or 0 for x in operations if contract_in_period(x, date(2026,1,1), date(2026,12,31)))
signed_q2 = sum(x["expected_contract_amount"] or 0 for x in operations if contract_in_period(x, date(2026,4,1), date(2026,6,30)))
signed_month = sum(x["expected_contract_amount"] or 0 for x in operations if contract_in_period(x, date(2026,6,1), date(2026,6,30)))
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 = {
@@ -363,37 +585,46 @@ def bootstrap():
"active_sales": len([x for x in sales if x["status"] in ["待跟进", "跟进中", "方案中", "商务谈判"]]), "active_sales": len([x for x in sales if x["status"] in ["待跟进", "跟进中", "方案中", "商务谈判"]]),
"execution_projects": len([x for x in operations if x["project_type"] == "execution"]), "execution_projects": len([x for x in operations if x["project_type"] == "execution"]),
"risk_projects": len([x for x in operations if x["project_status"] == "有风险" or x["risks"]]), "risk_projects": len([x for x in operations if x["project_status"] == "有风险" or x["risks"]]),
"monthly_revenue": revenue_month, "monthly_revenue": rev_month,
"monthly_net_profit": revenue_month - cost_month, "monthly_net_profit": gross_month,
"monthly_gross": gross_month,
"upcoming_products": len([x for x in products if x["status"] in ["规划中", "设计中", "开发中", "测试中"]]), "upcoming_products": len([x for x in products if x["status"] in ["规划中", "设计中", "开发中", "测试中"]]),
"total_projects": len(operations),
"total_proposals": len(proposals),
"total_products": len(products),
# Extended finance metrics # Extended finance metrics
"signed_amount": signed_amount, "signed_amount": signed_amount,
"signed_annual": signed_annual,
"signed_q2": signed_q2,
"signed_month": signed_month,
"pipeline_amount": pipeline_amount, "pipeline_amount": pipeline_amount,
"revenue_annual": revenue_annual, "revenue_annual": rev_annual,
"revenue_q2": revenue_q2, "revenue_q2": rev_q2,
"gross_annual": revenue_annual - cost_annual, "gross_annual": gross_annual,
"gross_q2": revenue_q2 - cost_q2, "gross_q2": gross_q2,
"signed_not_executed": signed_not_executed, "signed_not_executed": signed_not_executed,
}, },
"recent": rows(conn, "SELECT * FROM follow_up_records ORDER BY id DESC LIMIT 8"), "recent": q("SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 8", tenant),
"risks": [{"title": "执行提醒", "content": x["next_action"]} for x in operations if x["next_action"]][:5], "risks": [{"title": "执行提醒", "content": x["next_action"]} for x in operations if x["next_action"]][:5],
} }
return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "financeMonthly": monthly_finance(conn), "tasks": rows(conn, "SELECT * FROM project_tasks ORDER BY phase, id")}) return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "projectFinances": pfs, "financeMonthly": monthly_finance(conn, tenant), "tasks": tasks, "tenant": tenant, "tenants": allowed})
finally: finally:
conn.close() conn.close()
TABLES = { TABLES = {
"sales": ("sales_leads", ["target_customer", "priority", "status"]), "sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]),
"proposals": ("business_proposals", ["customer_or_project_name", "version", "description", "status", "created_date"]), "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"]), "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"]), "products": ("product_versions", ["product_name", "version", "version_goal", "feature_list", "launch_date", "status", "platform", "notes", "tenant"]),
"finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes"]), "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"]), "tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "priority", "tenant"]),
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "total_rev", "total_gross", "budget_data"]),
} }
@app.route("/api/<resource>", methods=["POST"]) @app.route("/api/<resource>", methods=["POST"])
@login_required
def create_resource(resource): def create_resource(resource):
if resource not in TABLES: if resource not in TABLES:
return jsonify({"error": "unknown resource"}), 404 return jsonify({"error": "unknown resource"}), 404
@@ -402,7 +633,7 @@ def create_resource(resource):
values = [payload.get(col, "") for col in cols] values = [payload.get(col, "") for col in cols]
conn = db() conn = db()
try: try:
cur = conn.execute(f"INSERT INTO {table} ({','.join(cols)}) VALUES ({','.join(['?'] * len(cols))})", values) cur = _exec(conn, f"INSERT INTO {table} ({','.join(cols)}) VALUES ({','.join(['?'] * len(cols))})", values)
conn.commit() conn.commit()
return jsonify({"id": cur.lastrowid}) return jsonify({"id": cur.lastrowid})
finally: finally:
@@ -410,6 +641,7 @@ def create_resource(resource):
@app.route("/api/<resource>/<int:item_id>", methods=["PUT", "DELETE"]) @app.route("/api/<resource>/<int:item_id>", methods=["PUT", "DELETE"])
@login_required
def update_resource(resource, item_id): def update_resource(resource, item_id):
if resource not in TABLES: if resource not in TABLES:
return jsonify({"error": "unknown resource"}), 404 return jsonify({"error": "unknown resource"}), 404
@@ -417,13 +649,13 @@ def update_resource(resource, item_id):
conn = db() conn = db()
try: try:
if request.method == "DELETE": if request.method == "DELETE":
conn.execute(f"DELETE FROM {table} WHERE id=?", (item_id,)) _exec(conn, f"DELETE FROM {table} WHERE id=?", (item_id,))
conn.commit() conn.commit()
return jsonify({"ok": True}) return jsonify({"ok": True})
payload = request.get_json(force=True).get("data", {}) payload = request.get_json(force=True).get("data", {})
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:
conn.execute( _exec(conn,
f"UPDATE {table} SET {','.join([col + '=?' for col in update_cols])}, updated_at=? WHERE id=?", f"UPDATE {table} SET {','.join([col + '=?' for col in update_cols])}, updated_at=? WHERE id=?",
[payload[col] for col in update_cols] + [now(), item_id], [payload[col] for col in update_cols] + [now(), item_id],
) )
@@ -434,14 +666,15 @@ def update_resource(resource, item_id):
@app.route("/api/followups/<target_type>/<int:target_id>", methods=["POST"]) @app.route("/api/followups/<target_type>/<int:target_id>", methods=["POST"])
@login_required
def add_followup(target_type, target_id): def add_followup(target_type, target_id):
payload = request.get_json(force=True).get("data", {}) payload = request.get_json(force=True).get("data", {})
conn = db() conn = db()
try: try:
conn.execute( _exec(conn,
"""INSERT INTO follow_up_records """INSERT INTO follow_up_records
(target_type,target_id,followed_at,follower,follow_up_method,content,next_action,next_follow_up_at) (target_type,target_id,followed_at,follower,follow_up_method,content,next_action,next_follow_up_at,tenant)
VALUES (?,?,?,?,?,?,?,?)""", VALUES (?,?,?,?,?,?,?,?,?)""",
( (
target_type, target_type,
target_id, target_id,
@@ -451,6 +684,7 @@ def add_followup(target_type, target_id):
payload.get("content") or "", payload.get("content") or "",
payload.get("next_action") or "", payload.get("next_action") or "",
payload.get("next_follow_up_at") or "", payload.get("next_follow_up_at") or "",
payload.get("tenant") or "科普·无界",
), ),
) )
conn.commit() conn.commit()
@@ -460,10 +694,11 @@ def add_followup(target_type, target_id):
@app.route("/api/followups/<int:followup_id>", methods=["DELETE"]) @app.route("/api/followups/<int:followup_id>", methods=["DELETE"])
@login_required
def delete_followup(followup_id): def delete_followup(followup_id):
conn = db() conn = db()
try: try:
cur = conn.execute("DELETE FROM follow_up_records WHERE id=?", (followup_id,)) cur = _exec(conn, "DELETE FROM follow_up_records WHERE id=?", (followup_id,))
conn.commit() conn.commit()
if cur.rowcount == 0: if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404 return jsonify({"error": "not found"}), 404
@@ -472,7 +707,22 @@ def delete_followup(followup_id):
conn.close() conn.close()
@app.route("/api/tasks/batch-sort", methods=["POST"])
@login_required
def batch_sort_tasks():
conn = db()
try:
items = request.get_json(force=True).get("items", [])
for item in items:
_exec(conn, "UPDATE project_tasks SET sort_order=? WHERE id=?", (item["sort_order"], item["id"]))
conn.commit()
return jsonify({"ok": True})
finally:
conn.close()
@app.route("/api/files/upload", methods=["POST"]) @app.route("/api/files/upload", methods=["POST"])
@login_required
def upload_file(): def upload_file():
file = request.files["file"] file = request.files["file"]
module = request.form["module"] module = request.form["module"]
@@ -508,6 +758,7 @@ def file_content(file_id):
@app.route("/api/files/<int:file_id>", methods=["DELETE"]) @app.route("/api/files/<int:file_id>", methods=["DELETE"])
@login_required
def delete_file(file_id): def delete_file(file_id):
conn = db() conn = db()
try: try:
@@ -518,7 +769,7 @@ def delete_file(file_id):
path = Path(asset["file_path"]) path = Path(asset["file_path"])
if path.exists() and str(UPLOAD_DIR) in str(path.resolve()): if path.exists() and str(UPLOAD_DIR) in str(path.resolve()):
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
conn.execute("DELETE FROM file_assets WHERE id=?", (file_id,)) _exec(conn, "DELETE FROM file_assets WHERE id=?", (file_id,))
conn.commit() conn.commit()
return jsonify({"ok": True}) return jsonify({"ok": True})
finally: finally:

View File

@@ -1 +1,2 @@
Flask==3.0.3 Flask==3.0.3
python-dateutil==2.9.0

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,29 @@ body {
justify-content: space-between; justify-content: space-between;
} }
/* 工作台侧边栏 */
.workspace-nav-item {
width: 52px;
height: 52px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 14px;
color: #94a3b8;
cursor: pointer;
transition: all 0.15s;
margin-bottom: 2px;
}
.workspace-nav-item:hover {
color: #e2e8f0;
background: rgba(255,255,255,0.08);
}
.workspace-nav-item.active {
color: #60a5fa;
background: rgba(96,165,250,0.15);
}
.tabs { .tabs {
display: flex; display: flex;
gap: 4px; gap: 4px;
@@ -38,6 +61,478 @@ body {
display: block; display: block;
} }
/* 财务模态框 Tab */
.finance-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid #e2e8f0;
padding: 0 32px;
background: #fff;
}
.finance-tab {
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
color: #64748b;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.15s;
}
.finance-tab:hover { color: #1e293b; }
.finance-tab.active {
color: #1d4ed8;
border-bottom-color: #1d4ed8;
}
/* 项目统一卡片布局 */
.project-board {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
height: calc(100vh - 190px);
}
/* 项目树头部 */
.project-tree-hd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
color: #374151;
flex-shrink: 0;
}
.project-search {
display: flex;
align-items: center;
gap: 6px;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 5px 10px;
font-size: 13px;
}
.project-search i {
color: #9ca3af;
flex-shrink: 0;
}
.project-search input {
border: none;
outline: none;
font-size: 13px;
color: #374151;
width: 160px;
background: transparent;
}
.project-search input::placeholder { color: #9ca3af; }
.project-board-body {
display: flex;
flex: 1;
overflow: hidden;
}
/* 项目树 */
.project-tree {
width: 200px;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.project-tree-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.project-tree-node {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 13px;
color: #334155;
cursor: pointer;
transition: background 0.1s;
border-left: 3px solid transparent;
}
.project-tree-node:hover {
background: #f1f5f9;
}
.project-tree-node.active {
background: #eff6ff;
border-left-color: #3b82f6;
color: #1d4ed8;
font-weight: 500;
}
.project-tree-icon {
flex-shrink: 0;
display: flex;
align-items: center;
color: #94a3b8;
}
.project-tree-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-tree-empty {
padding: 20px 16px;
font-size: 13px;
color: #94a3b8;
text-align: center;
}
/* 右键菜单 */
.project-context-menu {
position: fixed;
z-index: 100;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
padding: 4px 0;
min-width: 140px;
}
.project-context-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
font-size: 12px;
color: #334155;
cursor: pointer;
}
.project-context-item:hover {
background: #f1f5f9;
}
.project-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #94a3b8;
font-size: 14px;
}
/* 台账任务流Plane 风格) */
.task-feed {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
min-width: 0;
}
.task-feed-hd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
flex-shrink: 0;
}
.task-feed-body {
flex: 1;
overflow-y: auto;
padding: 0;
}
.task-section {
border-bottom: 1px solid #edf2f7;
}
.task-section-hd {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: #fafbfc;
cursor: pointer;
user-select: none;
}
.task-section-toggle {
display: flex;
align-items: center;
color: #9ca3af;
transition: transform 0.15s;
}
.task-section-list-wrap.collapsed {
display: none;
}
.task-section-icon {
display: flex;
align-items: center;
color: #9ca3af;
}
.task-section-label {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.task-section-n {
background: #e5e7eb;
color: #6b7280;
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
.task-section-list {
/* flat list, no card */
}
.task-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 20px;
cursor: pointer;
transition: background 0.1s;
}
.task-item:hover {
background: #f9fafb;
}
.task-item.task-done .task-title {
text-decoration: line-through;
color: #9ca3af;
}
/* 状态徽章 */
.task-status-badge {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.status-未开始 { background: #f1f5f9; color: #64748b; }
.status-进行中 { background: #dbeafe; color: #1d4ed8; }
.status-验收中 { background: #fef3c7; color: #92400e; }
.status-已结束 { background: #dcfce7; color: #166534; }
/* 产品版本状态 */
.status-规划中 { background: #f1f5f9; color: #64748b; }
.status-设计中 { background: #ede9fe; color: #7c3aed; }
.status-开发中 { background: #dbeafe; color: #1d4ed8; }
.status-测试中 { background: #fef3c7; color: #92400e; }
.status-已上线 { background: #dcfce7; color: #166534; }
.status-已延期 { background: #fee2e2; color: #991b1b; }
.status-已取消 { background: #f1f5f9; color: #94a3b8; }
/* 优先级底色 */
.task-p0 { background: #fef2f2; }
.task-p0:hover { background: #fee2e2; }
.task-p1 { background: #fffbeb; }
.task-p1:hover { background: #fef3c7; }
.task-priority-badge {
flex-shrink: 0;
font-size: 11px;
font-weight: 700;
padding: 1px 6px;
border-radius: 4px;
}
.priority-p0 { background: #fecaca; color: #991b1b; }
.priority-p1 { background: #fde68a; color: #92400e; }
.priority-p2 { background: #e2e8f0; color: #475569; }
.priority-p3 { background: #f1f5f9; color: #94a3b8; }
.task-check {
flex-shrink: 0;
display: flex;
align-items: center;
color: #9ca3af;
cursor: pointer;
}
.task-check:hover {
color: #3b82f6;
}
.task-grip {
flex-shrink: 0;
color: #d1d5db;
cursor: grab;
padding: 2px 0;
display: flex;
align-items: center;
}
.task-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.task-feed .task-title {
font-weight: 400;
color: #1f2937;
font-size: 12px;
}
.task-desc {
font-size: 12px;
color: #9ca3af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 360px;
}
.task-meta {
flex-shrink: 0;
font-size: 12px;
color: #6b7280;
min-width: 56px;
text-align: right;
}
.task-blocker {
display: block;
font-size: 12px;
color: #ef4444;
margin-top: 2px;
}
.task-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #9ca3af;
font-size: 13px;
}
/* 业务方案列表项 */
.proposal-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
font-size: 13px;
transition: background 0.1s;
}
/* 上传任务列表 */
#uploadTaskList {
display: none;
padding: 12px 20px;
border-top: 1px solid #e2e8f0;
}
.upload-task {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
font-size: 12px;
}
.upload-task-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #334155;
}
.upload-task-bar {
width: 80px;
height: 4px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
flex-shrink: 0;
}
.upload-task-fill {
height: 100%;
background: #3b82f6;
border-radius: 2px;
transition: width 0.2s;
}
.upload-task-pct {
width: 32px;
text-align: right;
color: #6b7280;
font-size: 11px;
flex-shrink: 0;
}
.upload-task-cancel {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
color: #9ca3af;
cursor: pointer;
border-radius: 4px;
}
.upload-task-cancel:hover { color: #ef4444; background: #fef2f2; }
.proposal-item:hover { background: #f9fafb; }
.proposal-customer {
flex: 0 0 140px;
font-weight: 500;
color: #1f2937;
}
.proposal-notes {
flex: 1;
color: #6b7280;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.proposal-files {
flex-shrink: 0;
font-size: 12px;
color: #9ca3af;
}
.card { .card {
background: white; background: white;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
@@ -507,7 +1002,7 @@ td {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid #e2e8f0; padding: 16px 20px; border-bottom: 1px solid #e2e8f0;
} }
.task-title { color: #1e293b; font-size: 15px; font-weight: 600; } .task-drawer .task-title { color: #1e293b; font-size: 15px; font-weight: 600; }
.task-close { .task-close {
color: #94a3b8; background: none; border: none; cursor: pointer; color: #94a3b8; background: none; border: none; cursor: pointer;
padding: 4px; border-radius: 6px; display: flex; padding: 4px; border-radius: 6px; display: flex;
@@ -543,13 +1038,20 @@ td {
padding: 1px 7px; border-radius: 10px; padding: 1px 7px; border-radius: 10px;
} }
.task-group-list { display: flex; flex-direction: column; } .task-group-list { display: flex; flex-direction: column; }
.task-group-list.drag-over { background: #f0f9ff; }
.task-row { .task-row {
display: flex; align-items: center; gap: 16px; display: flex; align-items: center; gap: 16px;
padding: 10px 16px; border-top: 1px solid #f1f5f9; padding: 10px 16px; border-top: 1px solid #f1f5f9;
cursor: pointer; cursor: pointer; transition: background 0.15s;
} }
.task-row.dragging { opacity: 0.4; background: #f1f5f9; }
.task-row.task-done .task-name { text-decoration: line-through; color: #94a3b8; }
.task-row:hover { background: #f8fafc; } .task-row:hover { background: #f8fafc; }
.task-dot { display: flex; color: #cbd5e1; flex-shrink: 0; } .task-dot { display: flex; color: #cbd5e1; flex-shrink: 0; cursor: pointer; }
.task-dot:hover { color: #6366f1; }
.task-grip { display: flex; color: #cbd5e1; flex-shrink: 0; cursor: grab; }
.task-grip:hover { color: #94a3b8; }
.task-grip:active { cursor: grabbing; }
.task-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .task-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.task-name { color: #1e293b; font-size: 13px; } .task-name { color: #1e293b; font-size: 13px; }
.task-desc { color: #94a3b8; font-size: 12px; word-wrap: break-word; overflow-wrap: break-word; } .task-desc { color: #94a3b8; font-size: 12px; word-wrap: break-word; overflow-wrap: break-word; }

View File

@@ -26,20 +26,47 @@
<script src="{{ url_for('static', filename='vendor/lucide.js') }}" defer></script> <script src="{{ url_for('static', filename='vendor/lucide.js') }}" defer></script>
</head> </head>
<body class="min-h-screen bg-slate-50 text-slate-950"> <body class="min-h-screen bg-slate-50 text-slate-950">
<div class="flex min-h-screen">
<!-- 左侧工作台切换栏 -->
<aside class="w-[72px] bg-slate-900 flex flex-col items-center py-6 gap-1 shrink-0" id="workspaceSidebar">
<div class="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center mb-6 text-white font-bold text-sm">OPC</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>
<span class="text-[10px] mt-1">科普</span>
</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>
<span class="text-[10px] mt-1">科研</span>
</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="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
<span class="text-[10px] mt-1">医患</span>
</div>
<!-- 用户区 -->
<div class="mt-auto flex flex-col items-center gap-2 pt-4">
<div class="w-8 h-8 rounded-full bg-slate-700 flex items-center justify-center text-white text-[11px] font-medium" id="userAvatar" title=""></div>
<button class="text-[10px] text-slate-500 hover:text-red-400 transition-colors" onclick="doLogout()" title="退出登录">退出</button>
</div>
</aside>
<!-- 主内容区 -->
<div class="flex-1 min-w-0">
<header class="topbar border-b border-slate-200 bg-white px-8 py-5"> <header class="topbar border-b border-slate-200 bg-white px-8 py-5">
<div> <div class="flex items-center gap-3">
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager · 单用户 · 单项目</p> <div>
<h1 class="mt-1 text-2xl font-semibold">科普慰心斋OPC 工作台</h1> <p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager</p>
<div class="flex items-center gap-3 mt-1">
<h1 class="text-2xl font-semibold" id="workspaceTitle">科普 OPC 工作台</h1>
</div>
</div>
</div> </div>
<button id="refreshBtn" class="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium hover:bg-slate-50" type="button"><i data-lucide="refresh-cw"></i>刷新</button>
</header> </header>
<nav class="tabs border-b border-slate-200 bg-white px-8" id="tabs"> <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 class="active" data-tab="home"><i data-lucide="home"></i>首页</button>
<button data-tab="projects"><i data-lucide="briefcase-business"></i>重点项目</button> <button data-tab="finance"><i data-lucide="briefcase-business"></i>经营管理</button>
<button data-tab="proposals"><i data-lucide="file-text"></i>业务方案</button> <button data-tab="projects"><i data-lucide="file-text"></i>重点工作与台账</button>
<button data-tab="products"><i data-lucide="package"></i>产品研发</button> <button data-tab="proposals"><i data-lucide="package"></i>业务方案</button>
<button data-tab="finance"><i data-lucide="wallet-cards"></i>财务管理</button> <button data-tab="products"><i data-lucide="wallet-cards"></i>产品迭代</button>
</nav> </nav>
<main class="px-8 py-6"> <main class="px-8 py-6">
@@ -49,9 +76,51 @@
<section id="products" class="panel"></section> <section id="products" class="panel"></section>
<section id="finance" class="panel"></section> <section id="finance" class="panel"></section>
</main> </main>
</div><!-- 关闭主内容区 -->
</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="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm">
<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 class="bg-white rounded-2xl shadow-2xl w-full max-w-md 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="closeNewProjectModal()"><i data-lucide="x"></i></button>
</div>
<form onsubmit="createOperation(event)" class="p-6 grid gap-4">
<label class="block"><span class="text-xs font-medium text-slate-500">项目名称</span><input name="project_name" required class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm"></label>
<label class="block"><span class="text-xs font-medium text-slate-500">项目备注</span><textarea name="notes" rows="3" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm" placeholder="可选"></textarea></label>
<div class="flex justify-end gap-3 pt-3 border-t border-slate-100">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeNewProjectModal()">取消</button>
<button type="submit" class="btn btn-primary btn-sm">创建</button>
</div>
</form>
</div>
</div>
<script src="{{ url_for('static', filename='app.js') }}"></script> <script src="{{ url_for('static', filename='app.js') }}"></script>
</body> </body>
</html> </html>

45
templates/login.html Normal file
View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OPC 工作台 · 登录</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-slate-50 flex items-center justify-center">
<div class="bg-white rounded-2xl shadow-lg p-8 w-full max-w-sm mx-4">
<div class="text-center mb-6">
<h1 class="text-2xl font-bold text-slate-800">OPC 工作台</h1>
<p class="text-sm text-slate-400 mt-1">请输入账号密码登录</p>
</div>
<form id="loginForm" onsubmit="doLogin(event)" class="grid gap-4">
<label class="block">
<span class="text-xs font-medium text-slate-500">账号</span>
<input name="username" required class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm" placeholder="admin / kepu / keyan / yihuan" autofocus>
</label>
<label class="block">
<span class="text-xs font-medium text-slate-500">密码</span>
<input name="password" type="password" required class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm">
</label>
<p id="loginError" class="text-red-500 text-xs hidden"></p>
<button type="submit" class="btn w-full bg-slate-800 text-white rounded-lg py-2.5 text-sm font-medium hover:bg-slate-700">登 录</button>
</form>
<p class="text-xs text-slate-400 text-center mt-6">默认管理员qiukai / yxcowork2026</p>
</div>
<script>
async function doLogin(e) {
e.preventDefault();
const form = e.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
try {
const res = await (await fetch("/api/auth/login", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify(data) })).json();
if (res.error) { document.querySelector("#loginError").textContent = res.error; document.querySelector("#loginError").classList.remove("hidden"); return; }
window.location.href = "/";
} catch (err) {
document.querySelector("#loginError").textContent = "网络错误,请重试";
document.querySelector("#loginError").classList.remove("hidden");
}
}
</script>
</body>
</html>