v1.1.0-beta: 安全/性能/架构优化 + 账号管理后台 + 视图切换

## 安全与性能
- .env 环境变量、debug=False、except 改 mysql.connector.Error+logging
- attach_common 批量 IN 查询消除 N+1
- 批量 esc() XSS 转义

## 架构
- app.js 拆分为 7 模块 + admin.js
- .form-ctrl 统一表单控件

## 经营管理
- 字段改名:客户名称→项目名称、销售人员→商务负责人
- 必填:项目名称/商务负责人/经营负责人/签约月份/签约金额>0
- 视图切换:确收/毛利 ↔ 回款/费用

## 重点工作与台账
- 统计卡片样式与经营管理统一
- 任务状态简化 3 态
- 优先级点击切换、右键菜单(重命名/副本)
- 修复新建任务绑定错误项目 bug

## 用户体系
- 新增工作台:MCN·无界、无界·无界
- 新增账号:mcn/wuji
- 账号管理后台(admin 限定)
- sidebar 顶部头像+显示名,点击弹菜单
- sidebar sticky 定位

## 其他
- 登录页样式优化(参考 UOC 平台)
- 首页财务趋势拆 3 图
- 业务方案标准资料库双 Tab
This commit is contained in:
mac
2026-06-23 15:54:03 +08:00
parent 5b1dc4555f
commit 9b6257ff19
15 changed files with 2898 additions and 1749 deletions

View File

@@ -4,42 +4,99 @@
<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>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/lucide.js') }}" onerror="this.remove()">
<script src="{{ url_for('static', filename='vendor/lucide.js') }}"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:PingFang SC,Microsoft YaHei,-apple-system,sans-serif;background:#f8fafc;min-height:100vh;display:flex;align-items:center;justify-content:center}
.card{background:#fff;border-radius:16px;padding:40px 44px;width:400px;max-width:94vw;box-shadow:0 16px 48px rgba(15,23,42,.12)}
.logo{text-align:center;margin-bottom:32px}
.logo .icon{width:56px;height:56px;margin:0 auto 10px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#1e293b,#0f172a);border-radius:14px;color:#fff}
.logo h1{font-size:20px;font-weight:700;color:#1e293b}
.logo p{font-size:13px;color:#94a3b8;margin-top:4px}
.form-group{margin-bottom:18px}
label{display:block;font-size:13px;font-weight:500;color:#475569;margin-bottom:6px}
.input-wrap{position:relative}
input[type=text],input[type=password]{width:100%;height:42px;padding:0 14px;border:1px solid #cbd5e1;border-radius:8px;font-size:14px;outline:none;transition:.2s;font-family:inherit;color:#1e293b;background:#fff}
.input-wrap input{padding:0 40px 0 14px}
input[type=text]:focus,input[type=password]:focus,.input-wrap input:focus{border-color:#334155;box-shadow:0 0 0 3px rgba(51,65,85,.12)}
input::placeholder{color:#94a3b8}
.toggle-pwd{position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:#94a3b8;padding:4px;display:flex;align-items:center;justify-content:center;transition:.2s}
.toggle-pwd:hover{color:#334155}
.btn{width:100%;height:44px;background:#1e293b;color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:.2s;margin-top:8px}
.btn:hover{background:#0f172a}
.btn:active{transform:scale(.98)}
.err{background:#fef2f2;border:1px solid #fecaca;color:#dc2626;border-radius:8px;padding:10px 14px;font-size:13px;margin-bottom:16px;display:none}
.err.show{display:block}
.footer{text-align:center;margin-top:24px;font-size:11px;color:#94a3b8}
.footer strong{color:#334155}
</style>
</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>
<body>
<div class="card">
<div class="logo">
<div class="icon"><i data-lucide="layout-dashboard" style="width:28px;height:28px;stroke-width:2"></i></div>
<h1>OPC 工作台</h1>
<p>请登录后继续</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");
}
<div class="err" id="errMsg"></div>
<div class="form-group">
<label>用户名</label>
<input type="text" id="username" placeholder="请输入用户名" autocomplete="username" autofocus>
</div>
<div class="form-group">
<label>密码</label>
<div class="input-wrap">
<input type="password" id="password" placeholder="请输入密码" autocomplete="current-password">
<button type="button" class="toggle-pwd" onclick="togglePwd('password', this)" title="显示/隐藏密码"><i data-lucide="eye" style="width:18px;height:18px"></i></button>
</div>
</div>
<button class="btn" id="loginBtn" onclick="doLogin()">登 录</button>
<div class="footer">默认管理员:<strong>qiukai / yxcowork2026</strong></div>
</div>
<script>
if (window.lucide) lucide.createIcons();
function togglePwd(id, btn) {
const input = document.getElementById(id);
const isPwd = input.type === 'password';
input.type = isPwd ? 'text' : 'password';
btn.innerHTML = isPwd
? '<i data-lucide="eye-off" style="width:18px;height:18px"></i>'
: '<i data-lucide="eye" style="width:18px;height:18px"></i>';
if (window.lucide) lucide.createIcons();
}
const errEl = document.getElementById('errMsg');
function showErr(msg) { errEl.textContent = msg; errEl.classList.add('show'); }
function clearErr() { errEl.classList.remove('show'); }
document.getElementById('username').addEventListener('input', clearErr);
document.getElementById('password').addEventListener('input', clearErr);
// 回车提交
document.getElementById('password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
document.getElementById('username').addEventListener('keydown', (e) => { if (e.key === 'Enter') document.getElementById('password').focus(); });
async function doLogin() {
clearErr();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
if (!username || !password) { showErr('请输入用户名和密码'); return; }
const btn = document.getElementById('loginBtn');
btn.disabled = true; btn.style.opacity = .7; btn.textContent = '登录中...';
try {
const res = await (await fetch('/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
})).json();
if (res.error) { showErr(res.error); btn.disabled = false; btn.style.opacity = 1; btn.textContent = '登 录'; return; }
window.location.href = '/';
} catch (err) {
showErr('网络错误,请重试');
btn.disabled = false; btn.style.opacity = 1; btn.textContent = '登 录';
}
</script>
}
</script>
</body>
</html>