Compare commits

...

1 Commits

5 changed files with 212 additions and 68 deletions

View File

@@ -1,18 +1,37 @@
# OPC Manager Version Log
## opc-manager-v0.1.0 - 2026-05-30
## v1.0.0 2026-05-30
- Deployed the Flask/Jinja OPC workbench to the business server.
- Runtime path: `/opt/opc-manager`.
- Runtime service: `opc-manager.service` managed by systemd.
- Runtime command: `gunicorn -w 2 -b 127.0.0.1:5177 backend.flask_app:app`.
- Public URL: `https://opc.yxcowork.vip`.
- Health check: `https://opc.yxcowork.vip/api/health`.
- Database path: `/opt/opc-manager/data/opc.sqlite`.
- Caddy route: `opc.yxcowork.vip -> localhost:5177`.
**首次正式发布**
Deployment rule from this version onward:
### Features
- 首页概览7 项关键指标卡片4 列自动换行)、财务趋势图、风险提醒、近期动态
- 销售管理:客户表格 + 抽屉详情(字段失焦自动保存)
- 业务方案:版本表格 + 抽屉(文件上传/预览/下载/删除)
- 运营管理:项目表格(业务机会/已签约执行分类筛选)+ 抽屉
- 产品研发:版本表格 + 抽屉
- 财务管理:月度收入/毛利/成本/净利曲线图 + 明细表
- Every deployment must be committed to Git.
- Every deployment must create a corresponding Git tag.
- Every deployment must update this version log.
### Interactions
- 所有抽屉Plane 风格紧凑布局720px、字段失焦自动保存、状态指示
- 评论区Squire 富文本编辑器(加粗/斜体/下划线/删除线/无序列表/有序列表/引用/撤销/重做)
- 评论支持删除,带确认弹窗
- 评论内容保留 HTML 格式(加粗、列表等)
- 图标库Lucide
### Tech Stack
- Backend: Flask + SQLite
- Frontend: Vanilla JS + Tailwind CSS CDN
- Editor: Squire (Fastmail)
- Charts: Chart.js
- Icons: Lucide
### Fixes
- 首页财务图表空白问题:固定容器高度 140px + maintainAspectRatio: false
- 首页指标卡片布局grid-cols-7 → grid-cols-4 自动换行
- 风险提醒文字竖排grid-cols-2 等宽布局 + break-words
- 评论区工具栏按钮无效onclick → onmousedown 防止焦点丢失
- 格式 toggle 无效hasFormat 检测 + removeBold/removeItalic
- 列表按钮无效Squire API 替代 Trix
- 评论内容格式丢失encodeURIComponent 编码 + decodeURIComponent 渲染
- 列表显示无标记list-style: revert 覆盖 Tailwind reset

BIN
data/opc.sqlite Normal file

Binary file not shown.

View File

@@ -65,6 +65,28 @@ function render() {
renderProducts();
renderFinance();
if (window.lucide) window.lucide.createIcons();
// Decode and render rich HTML content in followup records
drawer.querySelectorAll(".rich-content").forEach((el) => {
const html = el.dataset.html;
if (html) el.innerHTML = decodeURIComponent(html);
});
// Initialize Squire editor
const squireDiv = drawer.querySelector(".squire-editor");
if (squireDiv && window.Squire) {
const id = squireDiv.id;
if (window.squireInstances[id]) window.squireInstances[id].destroy();
const sq = new Squire(squireDiv, { blockTag: "P" });
sq.addEventListener("input", () => {
const form = squireDiv.closest("form");
const btn = form.querySelector(".comment-submit");
});
window.squireInstances[id] = sq;
// Handle placeholder
squireDiv.addEventListener("focus", () => squireDiv.classList.add("focused"));
squireDiv.addEventListener("blur", () => {
if (!squireDiv.textContent.trim()) squireDiv.classList.remove("focused");
});
}
}
function renderHome() {
@@ -72,7 +94,7 @@ function renderHome() {
const m = summary.metrics;
document.querySelector("#home").innerHTML = `
<div class="grid gap-5">
<div class="grid grid-cols-7 gap-3">
<div class="grid grid-cols-4 gap-3">
${[
["P0 客户数", m.p0_customers, "sales"],
["跟进中销售机会", m.active_sales, "sales"],
@@ -83,11 +105,11 @@ function renderHome() {
["即将上线版本", 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("")}
</div>
<div class="grid grid-cols-[1.35fr_0.65fr] gap-5">
${card(`<div class="mb-4 flex items-center justify-between"><h2 class="text-lg font-bold">财务趋势</h2>${badge("YYYY-MM")}</div><canvas id="financeChart" height="125"></canvas>`, "p-5")}
${card(`<h2 class="text-lg font-bold">风险提醒</h2><div class="mt-3 grid gap-2">${(summary.risks.length ? summary.risks : [{ title: "暂无高风险", content: "当前无明确阻塞,按周更新即可。" }]).map((r) => `<div class="rounded-md border border-amber-200 bg-amber-50 p-3"><p class="font-bold text-amber-900">${r.title}</p><p class="mt-1 text-sm text-amber-800">${r.content}</p></div>`).join("")}</div>`, "p-5")}
<div class="grid grid-cols-2 gap-5">
${card(`<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">财务趋势</h2>${badge("YYYY-MM")}</div><div style="position:relative;height:140px"><canvas id="financeChart"></canvas></div>`, "p-4")}
${card(`<h2 class="text-lg font-bold">风险提醒</h2><div class="mt-3 grid gap-2">${(summary.risks.length ? summary.risks : [{ title: "暂无高风险", content: "当前无明确阻塞,按周更新即可。" }]).map((r) => `<div class="rounded-md border border-amber-200 bg-amber-50 p-3"><p class="font-bold text-amber-900">${r.title}</p><p class="mt-1 text-sm text-amber-800 break-words">${r.content}</p></div>`).join("")}</div>`, "p-5")}
</div>
${card(`<h2 class="text-lg font-bold">近期动态</h2><div class="mt-4 grid gap-2">${summary.recent.map((r) => `<div class="flex justify-between rounded-md bg-slate-50 px-3 py-2 text-sm"><span>${r.content}</span><span class="text-slate-500">${r.followed_at}</span></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 justify-between rounded-md bg-slate-50 px-3 py-2 text-sm"><span class="break-words">${r.content}</span><span class="text-slate-500">${r.followed_at}</span></div>`).join("")}</div>`, "p-5")}
</div>
`;
renderChart(financeMonthly);
@@ -108,7 +130,7 @@ function renderChart(data) {
{ label: "净利", data: data.map((x) => x.net_profit), borderColor: "#7c3aed", tension: 0.3 },
],
},
options: { responsive: true, plugins: { legend: { position: "bottom" } } },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } }, scales: { x: { ticks: { font: { size: 11 } }, grid: { display: false } }, y: { ticks: { font: { size: 11 } } } } },
});
}
@@ -250,7 +272,7 @@ function renderChartOn(id, data) {
{ label: "净利", data: data.map((x) => x.net_profit), borderColor: "#7c3aed", tension: 0.3 },
],
},
options: { responsive: true, plugins: { legend: { position: "bottom" } } },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } }, scales: { x: { ticks: { font: { size: 11 } }, grid: { display: false } }, y: { ticks: { font: { size: 11 } } } } },
});
}
@@ -296,12 +318,24 @@ function openDrawer(resource, id) {
${resource === "proposals" ? `<section><h3 class="drawer-section-title">方案文件</h3><div class="grid gap-2">${["方案","成本","SOP","财务流程"].map((cat) => fileGroup("proposal", item.id, item.version, cat, item.files.filter((f) => f.file_category === cat))).join("")}</div></section>` : ""}
${followupTarget ? `<section>
<h3 class="drawer-section-title">活动 / 跟进</h3>
<div class="grid gap-2">${(item.followups || []).map((f) => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${f.follower} · ${f.follow_up_method}</span><span>${f.followed_at}</span></div><div class="mt-1 leading-5 text-slate-800 trix-content">${f.content}</div>${f.next_action ? `<p class="mt-1 leading-5 text-blue-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" onclick="deleteFollowup(event, ${f.id}, '${resource}', ${item.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")}</div>
<div class="grid gap-2">${(item.followups || []).map((f) => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${f.follower} · ${f.follow_up_method}</span><span>${f.followed_at}</span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html="${encodeURIComponent(f.content || '')}"></div>${f.next_action ? `<p class="mt-1 leading-5 text-blue-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" onclick="deleteFollowup(event, ${f.id}, '${resource}', ${item.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")}</div>
<form class="comment-box mt-3" onsubmit="submitComment(event,'${followupTarget}',${item.id},'${resource}')">
<input id="commentHidden_${resource}_${item.id}" type="hidden" name="content">
<trix-editor input="commentHidden_${resource}_${item.id}" placeholder="添加评论" class="comment-trix"></trix-editor>
<div class="squire-toolbar">
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('bold')" title="加粗"><i data-lucide="bold"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('italic')" title="斜体"><i data-lucide="italic"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('underline')" title="下划线"><i data-lucide="underline"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('strikethrough')" title="删除线"><i data-lucide="strikethrough"></i></button>
<span class="squire-sep"></span>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('makeUnorderedList')" title="无序列表"><i data-lucide="list"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('makeOrderedList')" title="有序列表"><i data-lucide="list-ordered"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('blockquote')" title="引用"><i data-lucide="quote"></i></button>
<span class="squire-sep"></span>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('undo')" title="撤销"><i data-lucide="undo"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('redo')" title="重做"><i data-lucide="redo"></i></button>
</div>
<div class="squire-editor" id="squire_${resource}_${item.id}" placeholder="添加评论"></div>
<div class="comment-toolbar">
<span class="comment-hint">支持 Markdown 格式</span>
<span class="comment-hint">支持富文本编辑</span>
<button class="btn btn-primary btn-sm comment-submit" type="submit">评论</button>
</div>
</form>
@@ -310,6 +344,28 @@ function openDrawer(resource, id) {
drawer.classList.add("open");
bindDrawerAutosave(resource, item.id, item);
if (window.lucide) window.lucide.createIcons();
// Decode and render rich HTML content in followup records
drawer.querySelectorAll(".rich-content").forEach((el) => {
const html = el.dataset.html;
if (html) el.innerHTML = decodeURIComponent(html);
});
// Initialize Squire editor
const squireDiv = drawer.querySelector(".squire-editor");
if (squireDiv && window.Squire) {
const id = squireDiv.id;
if (window.squireInstances[id]) window.squireInstances[id].destroy();
const sq = new Squire(squireDiv, { blockTag: "P" });
sq.addEventListener("input", () => {
const form = squireDiv.closest("form");
const btn = form.querySelector(".comment-submit");
});
window.squireInstances[id] = sq;
// Handle placeholder
squireDiv.addEventListener("focus", () => squireDiv.classList.add("focused"));
squireDiv.addEventListener("blur", () => {
if (!squireDiv.textContent.trim()) squireDiv.classList.remove("focused");
});
}
}
function setDrawerSaveStatus(message, tone = "muted") {
@@ -352,12 +408,36 @@ function bindDrawerAutosave(resource, id, item) {
window.openDrawer = openDrawer;
window.closeDrawer = () => document.querySelector("#drawer").classList.remove("open");
window.squireInstances = {};
window.squireCmd = (cmd) => {
const currentEditor = document.querySelector(".squire-editor");
if (!currentEditor) return;
const id = currentEditor.id;
const sq = window.squireInstances[id];
if (!sq) return;
sq.focus();
setTimeout(() => {
if (cmd === "bold") {
sq.hasFormat("b") || sq.hasFormat("strong") ? sq.removeBold() : sq.bold();
} else if (cmd === "italic") {
sq.hasFormat("i") || sq.hasFormat("em") ? sq.removeItalic() : sq.italic();
} else if (cmd === "underline") {
sq.hasFormat("u") ? sq.changeFormat(null, { tag: "u" }, null) : sq.changeFormat({ tag: "u" }, null, null);
} else if (cmd === "strikethrough") {
sq.hasFormat("s") || sq.hasFormat("del") || sq.hasFormat("strike") ? sq.changeFormat(null, { tag: "s" }, null) : sq.changeFormat({ tag: "s" }, null, null);
} else {
sq[cmd]();
}
}, 10);
};
window.submitComment = async (event, targetType, targetId, resource) => {
event.preventDefault();
const form = event.currentTarget;
const editor = form.querySelector("trix-editor");
const content = editor.editor.getDocument().toString().trim();
if (!content) return;
const editorDiv = form.querySelector(".squire-editor");
const sq = window.squireInstances[editorDiv.id];
const content = sq ? sq.getHTML().trim() : "";
if (!content || content === "<div><br></div>" || content === "<p><br></p>") return;
const button = form.querySelector(".comment-submit");
button.disabled = true;
button.textContent = "发送中…";
@@ -374,10 +454,6 @@ window.deleteFollowup = async (event, followupId, resource, targetId) => {
openDrawer(resource, targetId);
};
document.querySelector("#drawer").addEventListener("click", (event) => {
if (event.target === event.currentTarget) closeDrawer();
});
document.querySelector("#tabs").addEventListener("click", (event) => {
const button = event.target.closest("button[data-tab]");
if (button) switchTab(button.dataset.tab);

View File

@@ -157,13 +157,14 @@ td {
box-shadow: -18px 0 45px rgba(15, 23, 42, 0.14);
height: 100vh;
overflow-y: auto;
width: 560px;
width: 720px;
}
.file-link {
color: #1d4ed8;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.drawer-section-title {
@@ -313,14 +314,50 @@ td {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.08);
}
/* Trix editor inside comment box */
.comment-trix {
min-height: 80px;
/* Squire editor */
.squire-toolbar {
align-items: center;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
display: flex;
gap: 2px;
padding: 5px 6px;
}
.comment-trix trix-editor {
.squire-btn {
align-items: center;
background: transparent;
border: none;
border-radius: 4px;
color: #475569;
cursor: pointer;
display: inline-flex;
height: 28px;
justify-content: center;
transition: background 0.15s;
width: 28px;
}
.squire-btn:hover {
background: #e2e8f0;
color: #0f172a;
}
.squire-btn [data-lucide] {
height: 15px;
width: 15px;
}
.squire-sep {
background: #e2e8f0;
display: inline-block;
height: 18px;
margin: 0 2px;
width: 1px;
}
.squire-editor {
border: 0;
border-radius: 0;
font-size: 13px;
line-height: 1.55;
min-height: 80px;
@@ -328,30 +365,32 @@ td {
outline: none;
}
.comment-trix trix-toolbar {
border: 0;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
padding: 6px 6px;
.squire-editor p {
margin: 0;
min-height: 1em;
}
.comment-trix trix-toolbar .trix-button {
border-radius: 4px;
padding: 3px 5px;
font-size: 12px;
background: transparent;
border-color: transparent;
.squire-editor ul,
.squire-editor ol {
list-style: revert;
padding-left: 24px;
margin: 4px 0;
}
.squire-editor ul {
list-style-type: disc;
}
.squire-editor ol {
list-style-type: decimal;
}
.squire-editor li {
margin: 2px 0;
}
.comment-trix trix-toolbar .trix-button:hover,
.comment-trix trix-toolbar .trix-button.trix-active {
background: #e2e8f0;
color: #1e293b;
}
.comment-trix trix-toolbar .trix-button-group {
border-color: #e2e8f0;
margin-right: 4px;
.squire-editor blockquote {
border-left: 3px solid #e2e8f0;
color: #64748b;
margin: 4px 0;
padding-left: 10px;
}
.comment-toolbar {
@@ -414,26 +453,37 @@ td {
width: 14px;
}
/* Trix content in activity items */
.activity-item .trix-content {
.activity-item .rich-content {
font-size: 13px;
line-height: 1.55;
}
.activity-item .trix-content div {
.activity-item .rich-content div {
font-size: 13px;
}
.activity-item .trix-content strong {
.activity-item .rich-content strong {
font-weight: 600;
}
.activity-item .trix-content a {
.activity-item .rich-content a {
color: #1d4ed8;
text-decoration: underline;
}
.activity-item .trix-content ul,
.activity-item .trix-content ol {
padding-left: 16px;
margin: 4px 0;
.activity-item .rich-content ul,
.activity-item .rich-content ol {
padding-left: 24px;
list-style: revert;
}
.activity-item .trix-content blockquote {
.activity-item .rich-content ul {
list-style-type: disc;
}
.activity-item .rich-content ol {
list-style-type: decimal;
}
.activity-item .rich-content ul,
.activity-item .rich-content ol,
.activity-item .rich-content li {
margin: 2px 0;
}
.activity-item .rich-content blockquote {
border-left: 3px solid #e2e8f0;
padding-left: 10px;
color: #64748b;

View File

@@ -22,9 +22,8 @@
</script>
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.8/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/trix@2/dist/trix.css">
<script src="https://cdn.jsdelivr.net/npm/squire-rte@1/build/squire-raw.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/trix@2/dist/trix.umd.min.js" defer></script>
</head>
<body class="min-h-screen bg-slate-50 text-slate-950">
<header class="topbar border-b border-slate-200 bg-white px-8 py-5">