feat: OPC 工作台 — 科普(慰心斋)单项目管理系统

Flask + Tailwind CSS + Trix + Chart.js + Lucide Icons + SQLite

- 首页概览:关键指标卡片、财务趋势图、风险提醒、近期动态
- 销售管理:客户表格 + 抽屉详情(自动保存 + 评论)
- 业务方案:版本表格 + 抽屉(文件上传/预览/删除 + 评论)
- 运营管理:项目表格(业务机会/执行项目分类)+ 抽屉
- 产品研发:版本表格 + 抽屉
- 财务管理:月度收入/毛利/成本/净利曲线图 + 明细表
- 所有抽屉:Plane 风格紧凑布局、字段失焦自动保存、Trix 富文本评论框、点击遮罩关闭
This commit is contained in:
mac
2026-05-30 00:08:28 +08:00
commit 8dc69f8bd6
12 changed files with 2059 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.venv/
frontend/node_modules/
frontend/dist/
data/uploads/
*.pyc
__pycache__/
.DS_Store

55
README.md Normal file
View File

@@ -0,0 +1,55 @@
# OPC Manager
科普慰心斋OPC 工作台首版实现。
## 技术栈
- App: Flask + Jinja2
- Styling: Tailwind CSS CDN + small local CSS
- Icons: Lucide browser CDN
- Charts: Chart.js CDN
- Interactions: 原生 JavaScript
- Database: SQLite, `data/opc.sqlite`
- Files: local filesystem, `data/uploads/`
## 项目结构
- `backend/flask_app.py`: Flask 主应用
- `templates/index.html`: Jinja2 页面模板
- `static/styles.css`: 少量自定义样式
- `static/app.js`: 原生 JS 页面交互
- `data/`: SQLite 数据库和上传文件
- `docs/design-system-summary.md`: ui-ux-pro-max-skill 设计系统摘要
- `docs/frontend-design-checklist.md`: frontend-design 页面级落地清单
- `frontend/`: React/Vite 历史草稿,不作为当前运行入口
## 运行
```bash
cd /Users/mac/天机阁/法阁/藏经阁/OPC-Manager
python3 -m venv .venv
. .venv/bin/activate
pip install -r backend/requirements.txt
python backend/flask_app.py
```
打开:
```text
http://127.0.0.1:5177
```
健康检查:
```bash
curl http://127.0.0.1:5177/api/health
```
## 已接入种子数据
- 销售:慰心斋客户分层中的 5 个客户
- 业务方案:信达生物 v1.5 方案、成本、SOP、财务流程文件索引
- 运营:圆心科技文章/视频/专访项目与运营文件索引
- 产品:慰心斋产品路线图中的 5 个产品版本
- 财务:首版财务样例和原财务 manager 合并方向

18
VERSION_LOG.md Normal file
View File

@@ -0,0 +1,18 @@
# OPC Manager Version Log
## opc-manager-v0.1.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:
- Every deployment must be committed to Git.
- Every deployment must create a corresponding Git tag.
- Every deployment must update this version log.

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@

553
backend/app/main.py Normal file
View File

@@ -0,0 +1,553 @@
from datetime import date, datetime
from pathlib import Path
import shutil
import sqlite3
from typing import Any, Dict, List, Optional
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import Column, DateTime, Float, Integer, String, Text, create_engine, select
from sqlalchemy.orm import Session, declarative_base, sessionmaker
ROOT = Path(__file__).resolve().parents[2]
DATA_DIR = ROOT / "data"
UPLOAD_DIR = DATA_DIR / "uploads"
DB_PATH = DATA_DIR / "opc.sqlite"
WEIXIN_BASE = Path("/Users/mac/天机阁/地阁/慰心斋")
OLD_FINANCE_DB = WEIXIN_BASE / "5、财务管理/mananger/data/finance.sqlite"
DATA_DIR.mkdir(parents=True, exist_ok=True)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
engine = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Base = declarative_base()
def now() -> datetime:
return datetime.utcnow()
class TimestampMixin:
created_at = Column(DateTime, default=now)
updated_at = Column(DateTime, default=now, onupdate=now)
class SalesLead(Base, TimestampMixin):
__tablename__ = "sales_leads"
id = Column(Integer, primary_key=True)
target_customer = Column(String, nullable=False)
priority = Column(String, default="P1")
status = Column(String, default="待跟进")
class FollowUpRecord(Base, TimestampMixin):
__tablename__ = "follow_up_records"
id = Column(Integer, primary_key=True)
target_type = Column(String, nullable=False)
target_id = Column(Integer, nullable=False)
followed_at = Column(String, default=lambda: date.today().isoformat())
follower = Column(String, default="慰心")
follow_up_method = Column(String, default="记录")
content = Column(Text, default="")
next_action = Column(Text, default="")
next_follow_up_at = Column(String, default="")
class BusinessProposal(Base, TimestampMixin):
__tablename__ = "business_proposals"
id = Column(Integer, primary_key=True)
customer_or_project_name = Column(String, nullable=False)
version = Column(String, nullable=False)
description = Column(Text, default="")
status = Column(String, default="草稿")
created_date = Column(String, default=lambda: date.today().isoformat())
class OperationProject(Base, TimestampMixin):
__tablename__ = "operation_projects"
id = Column(Integer, primary_key=True)
project_name = Column(String, nullable=False)
project_version = Column(String, default="v1.0")
project_type = Column(String, default="opportunity")
project_status = Column(String, default="线索发现")
current_stage = Column(String, default="")
owner = Column(String, default="慰心")
start_date = Column(String, default="")
end_date = Column(String, default="")
target_customer = Column(String, default="")
customer_need = Column(Text, default="")
expected_contract_amount = Column(Float, default=0)
expected_sign_date = Column(String, default="")
sign_probability = Column(Float, default=0)
next_action = Column(Text, default="")
related_business_proposal_id = Column(Integer, nullable=True)
sop_file_id = Column(Integer, nullable=True)
sop_stage = Column(String, default="")
execution_progress = Column(Float, default=0)
current_deliverable = Column(Text, default="")
risks = Column(Text, default="")
notes = Column(Text, default="")
class ProductVersion(Base, TimestampMixin):
__tablename__ = "product_versions"
id = Column(Integer, primary_key=True)
product_name = Column(String, nullable=False)
version = Column(String, nullable=False)
version_goal = Column(Text, default="")
feature_list = Column(Text, default="")
launch_date = Column(String, default="")
status = Column(String, default="规划中")
notes = Column(Text, default="")
class FinanceRecord(Base, TimestampMixin):
__tablename__ = "finance_records"
id = Column(Integer, primary_key=True)
month = Column(String, nullable=False)
project_name = Column(String, default="科普(慰心斋)")
record_type = Column(String, nullable=False)
category = Column(String, default="")
amount = Column(Float, default=0)
occurred_date = Column(String, default="")
notes = Column(Text, default="")
class FileAsset(Base, TimestampMixin):
__tablename__ = "file_assets"
id = Column(Integer, primary_key=True)
module = Column(String, nullable=False)
owner_id = Column(Integer, nullable=False)
owner_version = Column(String, default="")
file_category = Column(String, default="")
file_name = Column(String, nullable=False)
file_type = Column(String, default="")
file_size = Column(Integer, default=0)
file_path = Column(String, nullable=False)
is_external = Column(Integer, default=0)
notes = Column(Text, default="")
Base.metadata.create_all(bind=engine)
def to_dict(row: Any) -> Dict[str, Any]:
data = {c.name: getattr(row, c.name) for c in row.__table__.columns}
for key in ("created_at", "updated_at"):
if data.get(key):
data[key] = data[key].isoformat()
return data
def latest_followup(db: Session, target_type: str, target_id: int) -> str:
row = db.execute(
select(FollowUpRecord)
.where(FollowUpRecord.target_type == target_type, FollowUpRecord.target_id == target_id)
.order_by(FollowUpRecord.followed_at.desc(), FollowUpRecord.id.desc())
).scalar_one_or_none()
return row.content if row else ""
def list_files(db: Session, module: str, owner_id: int) -> List[Dict[str, Any]]:
rows = db.execute(
select(FileAsset).where(FileAsset.module == module, FileAsset.owner_id == owner_id).order_by(FileAsset.id.desc())
).scalars()
return [to_dict(x) for x in rows]
def create_file_asset(db: Session, module: str, owner_id: int, category: str, path: Path, version: str = "", external: bool = True):
if not path.exists():
return
asset = FileAsset(
module=module,
owner_id=owner_id,
owner_version=version,
file_category=category,
file_name=path.name,
file_type=path.suffix.lower().lstrip("."),
file_size=path.stat().st_size,
file_path=str(path),
is_external=1 if external else 0,
)
db.add(asset)
def seed_data() -> None:
db = SessionLocal()
try:
if db.query(SalesLead).count() > 0:
return
sales_rows = [
("齐鲁制药", "P0", "跟进中", "多产品线科普年度框架,需推进高层沟通。"),
("百利天恒", "P0", "方案中", "BL-B01D1 上市前医生教育机会,准备方案。"),
("信达生物", "P0", "已签约", "现有科普项目升级/续约,重点保障执行。"),
("三生制药", "P1", "待跟进", "多科室医生教育+患者科普机会。"),
("天广实生物", "P1", "待跟进", "血液肿瘤医生教育机会。"),
]
for name, priority, status, note in sales_rows:
lead = SalesLead(target_customer=name, priority=priority, status=status)
db.add(lead)
db.flush()
db.add(FollowUpRecord(target_type="sales", target_id=lead.id, content=note, next_action="明确下一次沟通人和时间"))
proposal = BusinessProposal(
customer_or_project_name="信达生物",
version="v1.5",
description="信达科普项目续约与报价方案",
status="已提交客户",
created_date="2026-05-28",
)
db.add(proposal)
db.flush()
proposal_dir = WEIXIN_BASE / "2、业务方案/信达/v1.5"
for category, names in {
"方案": ["整体方案.pptx", "整体方案.pdf"],
"成本": ["业务报价-2亿方案.xlsx", "业务报价-5250万方案.xlsx", "5、最新报价.xlsx"],
"SOP": ["SOP.docx"],
"财务流程": ["财务流程.docx"],
}.items():
for filename in names:
create_file_asset(db, "proposal", proposal.id, category, proposal_dir / filename, proposal.version)
ops = [
("圆心科技 科普文章项目", "v2026-文章", "execution", "SOP 执行中", "内容生产", 55, "文章内容生产与审核执行中"),
("圆心科技 科普视频项目", "v2026-视频", "execution", "SOP 执行中", "内容生产", 45, "视频脚本、拍摄与审核推进"),
("圆心科技 科普专访项目", "v2026-专访", "opportunity", "方案已提交", "商务推进", 0, "专访项目推动签约"),
]
op_dir = WEIXIN_BASE / "3、运营方案"
for name, version, kind, status, stage, progress, note in ops:
project = OperationProject(
project_name=name,
project_version=version,
project_type=kind,
project_status=status,
current_stage=stage,
target_customer="圆心科技",
customer_need="科普内容项目执行与管理",
expected_contract_amount=0 if kind == "execution" else 200,
expected_sign_date="2026-06",
sign_probability=70 if kind == "opportunity" else 100,
sop_stage=stage,
execution_progress=progress,
current_deliverable=note,
next_action="补齐版本要求文件并更新下一节点",
)
db.add(project)
db.flush()
db.add(FollowUpRecord(target_type="operation", target_id=project.id, content=note, next_action=project.next_action))
file_map = [
(1, "项目方案", "圆心科技--科普文章项目(1).pptx"),
(2, "项目方案", "圆心科技-科普视频项目(1).pptx"),
(3, "项目方案", "圆心科技-科普专访项目-2026年(1).pdf"),
(1, "项目管理手册", "圆心科技《项目管理手册》-2026年.pdf"),
(2, "审核标准", "科普项目-审核标准(文章-视频-音频).pdf"),
]
for project_id, category, filename in file_map:
project = db.get(OperationProject, project_id)
if project:
create_file_asset(db, "operation", project.id, category, op_dir / filename, project.project_version)
products = [
("妙手医生服务小程序", "v1.1", "视频任务增强 + 积分商城", "草稿箱、批量上传、积分商城、消息通知", "2026-Q3", "规划中"),
("数字化营销后台管理系统", "v1.2", "运营数据看板 + 智能审核", "医生活跃、任务完成率、AI 预审、渠道数据上报", "2026-Q3", "设计中"),
("妙手患者服务", "v0.5", "科普浏览 + 医生主页 MVP", "科普文章/视频浏览、医生主页、搜索", "2026-Q3", "规划中"),
("数字人内容平台", "v0.1", "基础数字人视频生成 MVP", "预设形象、AI 配音、脚本驱动、简单模板", "2026-Q3", "规划中"),
("渠道分发引擎", "v1.0", "六渠道统一分发", "分发 API、内容适配、分发排期、效果追踪", "2027-Q1", "规划中"),
]
for row in products:
product = ProductVersion(
product_name=row[0], version=row[1], version_goal=row[2], feature_list=row[3], launch_date=row[4], status=row[5]
)
db.add(product)
db.flush()
db.add(FollowUpRecord(target_type="product", target_id=product.id, content=f"{row[0]} {row[1]}{row[2]}", next_action="按路线图推进"))
finance_seed = [
("2026-05", "revenue", "信达生物续约确认收入", 120, "信达项目阶段确收"),
("2026-06", "revenue", "信达生物续约确认收入", 80, "信达项目尾款预估"),
("2026-05", "cost_expense", "内容生产", 32, "医生劳务与内容制作"),
("2026-05", "cost_expense", "运营管理", 16, "项目管理与渠道协同"),
("2026-06", "cost_expense", "渠道分发", 24, "投放与分发费用"),
]
for month, record_type, category, amount, notes in finance_seed:
db.add(FinanceRecord(month=month, record_type=record_type, category=category, amount=amount, occurred_date=f"{month}-01", notes=notes))
if OLD_FINANCE_DB.exists():
conn = sqlite3.connect(OLD_FINANCE_DB)
conn.row_factory = sqlite3.Row
for row in conn.execute("SELECT name, expected_revenue FROM customers WHERE expected_revenue > 0").fetchall():
db.add(
FinanceRecord(
month="2026-07",
record_type="revenue",
category=f"{row['name']} 预计确收",
amount=float(row["expected_revenue"] or 0),
occurred_date="2026-07-01",
notes="由原财务 manager 客户预算迁移",
)
)
conn.close()
db.commit()
finally:
db.close()
seed_data()
app = FastAPI(title="OPC Manager API")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class Payload(BaseModel):
data: Dict[str, Any]
def db_session():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_model(name: str):
models = {
"sales": SalesLead,
"followups": FollowUpRecord,
"proposals": BusinessProposal,
"operations": OperationProject,
"products": ProductVersion,
"finance": FinanceRecord,
}
if name not in models:
raise HTTPException(404, "unknown resource")
return models[name]
def followup_target(resource: str) -> str:
return {"sales": "sales", "operations": "operation", "products": "product"}.get(resource, resource)
def apply_payload(obj: Any, payload: Dict[str, Any]):
cols = {c.name for c in obj.__table__.columns}
for key, value in payload.items():
if key in cols and key not in {"id", "created_at", "updated_at"}:
setattr(obj, key, value)
@app.get("/api/summary")
def summary():
db = SessionLocal()
try:
sales = db.query(SalesLead).all()
ops = db.query(OperationProject).all()
products = db.query(ProductVersion).all()
finance = db.query(FinanceRecord).all()
current_month = "2026-05"
revenue = sum(x.amount for x in finance if x.month == current_month and x.record_type == "revenue")
cost_expense = sum(x.amount for x in finance if x.month == current_month and x.record_type == "cost_expense")
net_profit = revenue - cost_expense
risk_ops = [x for x in ops if x.project_status == "有风险" or x.risks]
recent = []
for record in db.query(FollowUpRecord).order_by(FollowUpRecord.id.desc()).limit(6):
recent.append(to_dict(record))
return {
"project_name": "科普(慰心斋)",
"metrics": {
"p0_customers": len([x for x in sales if x.priority == "P0"]),
"active_sales": len([x for x in sales if x.status in ["待跟进", "跟进中", "方案中", "商务谈判"]]),
"execution_projects": len([x for x in ops if x.project_type == "execution"]),
"risk_projects": len(risk_ops),
"monthly_revenue": revenue,
"monthly_net_profit": net_profit,
"upcoming_products": len([x for x in products if x.status in ["规划中", "设计中", "开发中", "测试中"]]),
},
"risks": [
{"title": "执行项目风险", "content": x.risks or f"{x.project_name} 需要按 SOP 更新下一节点"}
for x in risk_ops[:5]
],
"recent": recent,
}
finally:
db.close()
@app.get("/api/health")
def health():
return {"ok": True, "db": str(DB_PATH)}
@app.get("/api/{resource}")
def list_resource(resource: str):
db = SessionLocal()
try:
model = get_model(resource)
rows = db.query(model).order_by(model.id.desc()).all()
data = []
for row in rows:
item = to_dict(row)
if resource in ["sales", "operations", "products"]:
item["followups"] = [
to_dict(x)
for x in db.query(FollowUpRecord)
.filter(FollowUpRecord.target_type == followup_target(resource), FollowUpRecord.target_id == row.id)
.order_by(FollowUpRecord.followed_at.desc(), FollowUpRecord.id.desc())
]
item["latest_follow_up_record"] = item["followups"][0]["content"] if item["followups"] else ""
if resource == "proposals":
item["files"] = list_files(db, "proposal", row.id)
if resource == "operations":
item["files"] = list_files(db, "operation", row.id)
data.append(item)
return data
finally:
db.close()
@app.post("/api/{resource}")
def create_resource(resource: str, payload: Payload):
db = SessionLocal()
try:
model = get_model(resource)
obj = model()
apply_payload(obj, payload.data)
db.add(obj)
db.commit()
db.refresh(obj)
return to_dict(obj)
finally:
db.close()
@app.put("/api/{resource}/{item_id}")
def update_resource(resource: str, item_id: int, payload: Payload):
db = SessionLocal()
try:
model = get_model(resource)
obj = db.get(model, item_id)
if not obj:
raise HTTPException(404, "not found")
apply_payload(obj, payload.data)
db.commit()
db.refresh(obj)
return to_dict(obj)
finally:
db.close()
@app.delete("/api/{resource}/{item_id}")
def delete_resource(resource: str, item_id: int):
db = SessionLocal()
try:
model = get_model(resource)
obj = db.get(model, item_id)
if not obj:
raise HTTPException(404, "not found")
db.delete(obj)
db.commit()
return {"ok": True}
finally:
db.close()
@app.get("/api/finance/summary/monthly")
def finance_monthly():
db = SessionLocal()
try:
rows = db.query(FinanceRecord).all()
months = sorted({x.month for x in rows})
result = []
for month in months:
revenue = sum(x.amount for x in rows if x.month == month and x.record_type == "revenue")
cost_expense = sum(x.amount for x in rows if x.month == month and x.record_type == "cost_expense")
result.append(
{
"month": month,
"revenue": revenue,
"gross_profit": revenue - cost_expense,
"cost_expense": cost_expense,
"net_profit": revenue - cost_expense,
}
)
return result
finally:
db.close()
@app.post("/api/followups/{target_type}/{target_id}")
def add_followup(target_type: str, target_id: int, payload: Payload):
db = SessionLocal()
try:
record = FollowUpRecord(target_type=target_type, target_id=target_id)
apply_payload(record, payload.data)
db.add(record)
db.commit()
db.refresh(record)
return to_dict(record)
finally:
db.close()
@app.post("/api/files/upload")
async def upload_file(
module: str = Form(...),
owner_id: int = Form(...),
owner_version: str = Form(""),
file_category: str = Form(""),
file: UploadFile = File(...),
):
db = SessionLocal()
try:
folder = UPLOAD_DIR / module / str(owner_id)
folder.mkdir(parents=True, exist_ok=True)
target = folder / file.filename
with target.open("wb") as out:
shutil.copyfileobj(file.file, out)
asset = FileAsset(
module=module,
owner_id=owner_id,
owner_version=owner_version,
file_category=file_category,
file_name=file.filename,
file_type=Path(file.filename).suffix.lower().lstrip("."),
file_size=target.stat().st_size,
file_path=str(target),
is_external=0,
)
db.add(asset)
db.commit()
db.refresh(asset)
return to_dict(asset)
finally:
db.close()
@app.get("/api/files/{file_id}/content")
def file_content(file_id: int, inline: bool = True):
db = SessionLocal()
try:
asset = db.get(FileAsset, file_id)
if not asset:
raise HTTPException(404, "file not found")
path = Path(asset.file_path)
if not path.exists():
raise HTTPException(404, "file missing")
disposition = "inline" if inline else "attachment"
return FileResponse(path, filename=asset.file_name, content_disposition_type=disposition)
finally:
db.close()

479
backend/flask_app.py Normal file
View File

@@ -0,0 +1,479 @@
from datetime import date, datetime
from pathlib import Path
import os
import shutil
import sqlite3
from flask import Flask, jsonify, render_template, request, send_file
ROOT = Path(__file__).resolve().parents[1]
DATA_DIR = ROOT / "data"
UPLOAD_DIR = DATA_DIR / "uploads"
DB_PATH = DATA_DIR / "opc.sqlite"
WEIXIN_BASE = Path("/Users/mac/天机阁/地阁/慰心斋")
DATA_DIR.mkdir(parents=True, exist_ok=True)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
app = Flask(
__name__,
template_folder=str(ROOT / "templates"),
static_folder=str(ROOT / "static"),
)
def db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def now():
return datetime.utcnow().isoformat()
def rows(conn, sql, args=()):
return [dict(row) for row in conn.execute(sql, args).fetchall()]
def one(conn, sql, args=()):
row = conn.execute(sql, args).fetchone()
return dict(row) if row else None
def init_db():
conn = db()
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS sales_leads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_customer TEXT NOT NULL,
priority TEXT NOT NULL DEFAULT 'P1',
status TEXT NOT NULL DEFAULT '待跟进',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS follow_up_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_type TEXT NOT NULL,
target_id INTEGER NOT NULL,
followed_at TEXT NOT NULL DEFAULT '',
follower TEXT NOT NULL DEFAULT '慰心',
follow_up_method TEXT NOT NULL DEFAULT '记录',
content TEXT NOT NULL DEFAULT '',
next_action TEXT NOT NULL DEFAULT '',
next_follow_up_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS business_proposals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_or_project_name TEXT NOT NULL,
version TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '草稿',
created_date TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS operation_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_name TEXT NOT NULL,
project_version TEXT NOT NULL DEFAULT 'v1.0',
project_type TEXT NOT NULL DEFAULT 'opportunity',
project_status TEXT NOT NULL DEFAULT '',
current_stage TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL DEFAULT '慰心',
start_date TEXT NOT NULL DEFAULT '',
end_date TEXT NOT NULL DEFAULT '',
target_customer TEXT NOT NULL DEFAULT '',
customer_need TEXT NOT NULL DEFAULT '',
expected_contract_amount REAL NOT NULL DEFAULT 0,
expected_sign_date TEXT NOT NULL DEFAULT '',
sign_probability REAL NOT NULL DEFAULT 0,
next_action TEXT NOT NULL DEFAULT '',
related_business_proposal_id INTEGER,
sop_file_id INTEGER,
sop_stage TEXT NOT NULL DEFAULT '',
execution_progress REAL NOT NULL DEFAULT 0,
current_deliverable TEXT NOT NULL DEFAULT '',
risks TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS product_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_name TEXT NOT NULL,
version TEXT NOT NULL,
version_goal TEXT NOT NULL DEFAULT '',
feature_list TEXT NOT NULL DEFAULT '',
launch_date TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '规划中',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS finance_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
month TEXT NOT NULL,
project_name TEXT NOT NULL DEFAULT '科普(慰心斋)',
record_type TEXT NOT NULL,
category TEXT NOT NULL DEFAULT '',
amount REAL NOT NULL DEFAULT 0,
occurred_date TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS file_assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module TEXT NOT NULL,
owner_id INTEGER NOT NULL,
owner_version TEXT NOT NULL DEFAULT '',
file_category TEXT NOT NULL DEFAULT '',
file_name TEXT NOT NULL,
file_type TEXT NOT NULL DEFAULT '',
file_size INTEGER NOT NULL DEFAULT 0,
file_path TEXT NOT NULL,
is_external INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
"""
)
if one(conn, "SELECT id FROM sales_leads LIMIT 1"):
conn.close()
return
sales = [
("齐鲁制药", "P0", "跟进中", "多产品线科普年度框架,需推进高层沟通。"),
("百利天恒", "P0", "方案中", "BL-B01D1 上市前医生教育机会,准备方案。"),
("信达生物", "P0", "已签约", "现有科普项目升级/续约,重点保障执行。"),
("三生制药", "P1", "待跟进", "多科室医生教育+患者科普机会。"),
("天广实生物", "P1", "待跟进", "血液肿瘤医生教育机会。"),
]
for customer, priority, status, note in sales:
cur = conn.execute(
"INSERT INTO sales_leads (target_customer, priority, status) VALUES (?,?,?)",
(customer, priority, status),
)
conn.execute(
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("sales", cur.lastrowid, date.today().isoformat(), note, "明确下一次沟通人和时间"),
)
cur = conn.execute(
"INSERT INTO business_proposals (customer_or_project_name,version,description,status,created_date) VALUES (?,?,?,?,?)",
("信达生物", "v1.5", "信达科普项目续约与报价方案", "已提交客户", "2026-05-28"),
)
proposal_id = cur.lastrowid
proposal_dir = WEIXIN_BASE / "2、业务方案/信达/v1.5"
for category, names in {
"方案": ["整体方案.pptx", "整体方案.pdf"],
"成本": ["业务报价-2亿方案.xlsx", "业务报价-5250万方案.xlsx", "5、最新报价.xlsx"],
"SOP": ["SOP.docx"],
"财务流程": ["财务流程.docx"],
}.items():
for name in names:
add_file_index(conn, "proposal", proposal_id, "v1.5", category, proposal_dir / name, external=True)
projects = [
("圆心科技 科普文章项目", "v2026-文章", "execution", "SOP 执行中", "内容生产", 55, "文章内容生产与审核执行中"),
("圆心科技 科普视频项目", "v2026-视频", "execution", "SOP 执行中", "内容生产", 45, "视频脚本、拍摄与审核推进"),
("圆心科技 科普专访项目", "v2026-专访", "opportunity", "方案已提交", "商务推进", 0, "专访项目推动签约"),
]
op_dir = WEIXIN_BASE / "3、运营方案"
for name, version, kind, status, stage, progress, note in projects:
cur = conn.execute(
"""INSERT INTO operation_projects
(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)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(name, version, kind, status, stage, "圆心科技", "科普内容项目执行与管理", 0 if kind == "execution" else 200, "2026-06", 100 if kind == "execution" else 70, "补齐版本要求文件并更新下一节点", stage, progress, note),
)
conn.execute(
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("operation", cur.lastrowid, date.today().isoformat(), note, "补齐版本要求文件并更新下一节点"),
)
file_map = [
(1, "v2026-文章", "项目方案", "圆心科技--科普文章项目(1).pptx"),
(2, "v2026-视频", "项目方案", "圆心科技-科普视频项目(1).pptx"),
(3, "v2026-专访", "项目方案", "圆心科技-科普专访项目-2026年(1).pdf"),
(1, "v2026-文章", "项目管理手册", "圆心科技《项目管理手册》-2026年.pdf"),
(2, "v2026-视频", "审核标准", "科普项目-审核标准(文章-视频-音频).pdf"),
]
for project_id, version, category, filename in file_map:
add_file_index(conn, "operation", project_id, version, category, op_dir / filename, external=True)
products = [
("妙手医生服务小程序", "v1.1", "视频任务增强 + 积分商城", "草稿箱、批量上传、积分商城、消息通知", "2026-Q3", "规划中"),
("数字化营销后台管理系统", "v1.2", "运营数据看板 + 智能审核", "医生活跃、任务完成率、AI 预审、渠道数据上报", "2026-Q3", "设计中"),
("妙手患者服务", "v0.5", "科普浏览 + 医生主页 MVP", "科普文章/视频浏览、医生主页、搜索", "2026-Q3", "规划中"),
("数字人内容平台", "v0.1", "基础数字人视频生成 MVP", "预设形象、AI 配音、脚本驱动、简单模板", "2026-Q3", "规划中"),
("渠道分发引擎", "v1.0", "六渠道统一分发", "分发 API、内容适配、分发排期、效果追踪", "2027-Q1", "规划中"),
]
for product in products:
cur = conn.execute(
"INSERT INTO product_versions (product_name,version,version_goal,feature_list,launch_date,status) VALUES (?,?,?,?,?,?)",
product,
)
conn.execute(
"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]}", "按路线图推进"),
)
for month, record_type, category, amount, notes in [
("2026-05", "revenue", "信达生物续约确认收入", 120, "信达项目阶段确收"),
("2026-06", "revenue", "信达生物续约确认收入", 80, "信达项目尾款预估"),
("2026-05", "cost_expense", "内容生产", 32, "医生劳务与内容制作"),
("2026-05", "cost_expense", "运营管理", 16, "项目管理与渠道协同"),
("2026-06", "cost_expense", "渠道分发", 24, "投放与分发费用"),
]:
conn.execute(
"INSERT INTO finance_records (month,record_type,category,amount,occurred_date,notes) VALUES (?,?,?,?,?,?)",
(month, record_type, category, amount, f"{month}-01", notes),
)
conn.commit()
conn.close()
def add_file_index(conn, module, owner_id, owner_version, category, path, external=True):
path = Path(path)
if not path.exists():
return
conn.execute(
"""INSERT INTO file_assets
(module,owner_id,owner_version,file_category,file_name,file_type,file_size,file_path,is_external)
VALUES (?,?,?,?,?,?,?,?,?)""",
(module, owner_id, owner_version, category, path.name, path.suffix.lower().lstrip("."), path.stat().st_size, str(path), 1 if external else 0),
)
def latest_followup(conn, target_type, target_id):
row = one(
conn,
"SELECT content FROM follow_up_records WHERE target_type=? AND target_id=? ORDER BY followed_at DESC, id DESC LIMIT 1",
(target_type, target_id),
)
return row["content"] if row else ""
def attach_common(conn, resource, items):
target_map = {"sales": "sales", "proposals": "proposal", "operations": "operation", "products": "product"}
for item in items:
if resource in target_map:
item["followups"] = rows(
conn,
"SELECT * FROM follow_up_records WHERE target_type=? AND target_id=? ORDER BY followed_at DESC, id DESC",
(target_map[resource], item["id"]),
)
item["latest_follow_up_record"] = item["followups"][0]["content"] if item["followups"] else ""
if resource == "proposals":
item["files"] = rows(conn, "SELECT * FROM file_assets WHERE module='proposal' AND owner_id=? ORDER BY id DESC", (item["id"],))
if resource == "operations":
item["files"] = rows(conn, "SELECT * FROM file_assets WHERE module='operation' AND owner_id=? ORDER BY id DESC", (item["id"],))
return items
def monthly_finance(conn):
data = []
for item in rows(conn, "SELECT DISTINCT month FROM finance_records ORDER BY month"):
month = item["month"]
revenue = one(conn, "SELECT COALESCE(SUM(amount),0) AS v FROM finance_records WHERE month=? AND record_type='revenue'", (month,))["v"]
cost = one(conn, "SELECT COALESCE(SUM(amount),0) AS v FROM finance_records WHERE month=? AND record_type='cost_expense'", (month,))["v"]
data.append({"month": month, "revenue": revenue, "gross_profit": revenue - cost, "cost_expense": cost, "net_profit": revenue - cost})
return data
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/bootstrap")
def bootstrap():
conn = db()
try:
sales = attach_common(conn, "sales", rows(conn, "SELECT * FROM sales_leads ORDER BY id DESC"))
proposals = attach_common(conn, "proposals", rows(conn, "SELECT * FROM business_proposals ORDER BY id DESC"))
operations = attach_common(conn, "operations", rows(conn, "SELECT * FROM operation_projects ORDER BY id DESC"))
products = attach_common(conn, "products", rows(conn, "SELECT * FROM product_versions ORDER BY id DESC"))
finance = rows(conn, "SELECT * FROM finance_records ORDER BY month DESC, id DESC")
current_month = "2026-05"
revenue = sum(x["amount"] for x in finance if x["month"] == current_month and x["record_type"] == "revenue")
cost = sum(x["amount"] for x in finance if x["month"] == current_month and x["record_type"] == "cost_expense")
summary = {
"project_name": "科普(慰心斋)",
"metrics": {
"p0_customers": len([x for x in sales if x["priority"] == "P0"]),
"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"]),
"risk_projects": len([x for x in operations if x["project_status"] == "有风险" or x["risks"]]),
"monthly_revenue": revenue,
"monthly_net_profit": revenue - cost,
"upcoming_products": len([x for x in products if x["status"] in ["规划中", "设计中", "开发中", "测试中"]]),
},
"recent": rows(conn, "SELECT * FROM follow_up_records ORDER BY id DESC LIMIT 8"),
"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)})
finally:
conn.close()
TABLES = {
"sales": ("sales_leads", ["target_customer", "priority", "status"]),
"proposals": ("business_proposals", ["customer_or_project_name", "version", "description", "status", "created_date"]),
"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"]),
"products": ("product_versions", ["product_name", "version", "version_goal", "feature_list", "launch_date", "status", "notes"]),
"finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes"]),
}
@app.route("/api/<resource>", methods=["POST"])
def create_resource(resource):
if resource not in TABLES:
return jsonify({"error": "unknown resource"}), 404
table, cols = TABLES[resource]
payload = request.get_json(force=True).get("data", {})
values = [payload.get(col, "") for col in cols]
conn = db()
try:
cur = conn.execute(f"INSERT INTO {table} ({','.join(cols)}) VALUES ({','.join(['?'] * len(cols))})", values)
conn.commit()
return jsonify({"id": cur.lastrowid})
finally:
conn.close()
@app.route("/api/<resource>/<int:item_id>", methods=["PUT", "DELETE"])
def update_resource(resource, item_id):
if resource not in TABLES:
return jsonify({"error": "unknown resource"}), 404
table, cols = TABLES[resource]
conn = db()
try:
if request.method == "DELETE":
conn.execute(f"DELETE FROM {table} WHERE id=?", (item_id,))
conn.commit()
return jsonify({"ok": True})
payload = request.get_json(force=True).get("data", {})
update_cols = [col for col in cols if col in payload]
if update_cols:
conn.execute(
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],
)
conn.commit()
return jsonify({"ok": True})
finally:
conn.close()
@app.route("/api/followups/<target_type>/<int:target_id>", methods=["POST"])
def add_followup(target_type, target_id):
payload = request.get_json(force=True).get("data", {})
conn = db()
try:
conn.execute(
"""INSERT INTO follow_up_records
(target_type,target_id,followed_at,follower,follow_up_method,content,next_action,next_follow_up_at)
VALUES (?,?,?,?,?,?,?,?)""",
(
target_type,
target_id,
payload.get("followed_at") or date.today().isoformat(),
payload.get("follower") or "慰心",
payload.get("follow_up_method") or "记录",
payload.get("content") or "",
payload.get("next_action") or "",
payload.get("next_follow_up_at") or "",
),
)
conn.commit()
return jsonify({"ok": True})
finally:
conn.close()
@app.route("/api/followups/<int:followup_id>", methods=["DELETE"])
def delete_followup(followup_id):
conn = db()
try:
cur = conn.execute("DELETE FROM follow_up_records WHERE id=?", (followup_id,))
conn.commit()
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True})
finally:
conn.close()
@app.route("/api/files/upload", methods=["POST"])
def upload_file():
file = request.files["file"]
module = request.form["module"]
owner_id = int(request.form["owner_id"])
owner_version = request.form.get("owner_version", "")
category = request.form.get("file_category", "")
folder = UPLOAD_DIR / module / str(owner_id)
folder.mkdir(parents=True, exist_ok=True)
target = folder / file.filename
file.save(target)
conn = db()
try:
add_file_index(conn, module, owner_id, owner_version, category, target, external=False)
conn.commit()
return jsonify({"ok": True})
finally:
conn.close()
@app.route("/api/files/<int:file_id>/content")
def file_content(file_id):
conn = db()
try:
asset = one(conn, "SELECT * FROM file_assets WHERE id=?", (file_id,))
if not asset:
return jsonify({"error": "not found"}), 404
path = Path(asset["file_path"])
if not path.exists():
return jsonify({"error": "missing"}), 404
return send_file(path, as_attachment=request.args.get("inline") == "false", download_name=asset["file_name"])
finally:
conn.close()
@app.route("/api/files/<int:file_id>", methods=["DELETE"])
def delete_file(file_id):
conn = db()
try:
asset = one(conn, "SELECT * FROM file_assets WHERE id=?", (file_id,))
if not asset:
return jsonify({"error": "not found"}), 404
# Remove physical file from uploads/ if it was uploaded to our dir
path = Path(asset["file_path"])
if path.exists() and str(UPLOAD_DIR) in str(path.resolve()):
path.unlink(missing_ok=True)
conn.execute("DELETE FROM file_assets WHERE id=?", (file_id,))
conn.commit()
return jsonify({"ok": True})
finally:
conn.close()
@app.route("/api/health")
def health():
return jsonify({"ok": True, "db": str(DB_PATH)})
init_db()
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5177, debug=True)

1
backend/requirements.txt Normal file
View File

@@ -0,0 +1 @@
Flask==3.0.3

View File

@@ -0,0 +1,29 @@
# OPC Manager Design System Summary
Project: 科普慰心斋OPC 工作台
Purpose: single-project operating dashboard for sales, business proposals, operations, product versions, and finance.
## Design Direction
- Style: enterprise operations dashboard, not a marketing landing page.
- Priority: clarity, scan speed, table density, stable editing flows.
- Primary color: calm blue for navigation and primary actions.
- Status colors: red for risk, amber for pending, green for completed, slate for archived.
- Surface system: white content surfaces on a light slate background, thin borders, compact spacing.
## Layout Rules
- Default view is the overview dashboard.
- Use top tabs for primary modules.
- Use tables for sales and operations.
- Use cards only for repeated version entities: business proposals and product versions.
- Use right-side drawers for editable details.
- Use Recharts line charts for finance trends, with detail tables below.
## Component Rules
- Tables should fit meeting review scenarios: compact rows, visible status badges, latest activity.
- Drawers should contain editable fields and timelines without navigating away.
- File groups should show category, file name, preview, download, and upload affordances.
- Charts must use the same data as detail tables.

View File

@@ -0,0 +1,28 @@
# OPC Manager Frontend Design Checklist
## Before Development
- Build first screen as the operating dashboard, not a landing page.
- Keep dashboard metrics visible above the fold.
- Keep module navigation persistent and obvious.
- Prefer shadcn-style controls: Button, Tabs, Table, Sheet, Form, Input, Select, Textarea, Badge, Card.
- Use Tailwind tokens consistently for spacing, border, radius, color, and typography.
## Page-Level Checks
- 首页: metrics, risk reminders, recent activity, finance trend preview.
- 销售: compact table, priority badge, status badge, latest timeline entry, drawer edit flow.
- 业务方案: one version per card, four file groups, upload, preview, download.
- 运营: type filter, opportunity/execution fields, SOP execution visibility, project-version file ownership.
- 产品: one version per card, drawer editing, feature list readability.
- 财务: trend chart and detail rows reconcile with the same source data.
## Post-Development Audit
- No decorative landing sections before the dashboard.
- No nested cards that reduce scan speed.
- Drawers do not overflow or hide save actions.
- Tables remain readable at desktop widths.
- Status colors are consistent across modules.
- Preview and download actions are available for uploaded/indexed files.

388
static/app.js Normal file
View File

@@ -0,0 +1,388 @@
const state = {
active: "home",
data: null,
opFilter: "all",
chart: null,
};
const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")}`;
const text = (value) => value === undefined || value === null || value === "" ? "—" : value;
async function api(path, options = {}) {
const response = await fetch(path, {
headers: options.body instanceof FormData ? undefined : { "Content-Type": "application/json" },
...options,
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "请求失败");
return data;
}
function badge(value) {
const val = String(value || "—");
let cls = "badge-slate";
if (["P0", "有风险", "已丢单", "已延期"].includes(val)) cls = "badge-red";
if (["P1", "方案中", "方案已提交", "商务谈判", "待客户确认"].includes(val)) cls = "badge-amber";
if (["已签约", "已上线", "已完成", "已归档"].includes(val)) cls = "badge-green";
if (["execution", "已签约执行项目"].includes(val)) cls = "badge-blue";
return `<span class="badge ${cls}">${val === "execution" ? "已签约执行项目" : val === "opportunity" ? "业务机会项目" : val}</span>`;
}
function card(content, cls = "") {
return `<section class="card ${cls}">${content}</section>`;
}
function renderTable(headers, rows, rowClicks) {
const trAttrs = (rowClicks || []).map((rc) => rc ? `onclick="openDrawer('${rc.resource}', ${rc.id})" class="clickable-row"` : "");
return card(`
<div class="overflow-x-auto">
<table>
<thead><tr>${headers.map((h) => `<th>${h}</th>`).join("")}</tr></thead>
<tbody>${rows.map((row, i) => `<tr ${trAttrs[i] || ""}>${row.map((c) => `<td>${c}</td>`).join("")}</tr>`).join("")}</tbody>
</table>
</div>
`);
}
async function load() {
state.data = await api("/api/bootstrap");
render();
}
function switchTab(tab) {
state.active = tab;
document.querySelectorAll("#tabs button").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === tab));
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab));
render();
}
function render() {
if (!state.data) return;
renderHome();
renderSales();
renderProposals();
renderOperations();
renderProducts();
renderFinance();
if (window.lucide) window.lucide.createIcons();
}
function renderHome() {
const { summary, financeMonthly } = state.data;
const m = summary.metrics;
document.querySelector("#home").innerHTML = `
<div class="grid gap-5">
<div class="grid grid-cols-7 gap-3">
${[
["P0 客户数", m.p0_customers, "sales"],
["跟进中销售机会", m.active_sales, "sales"],
["已签约执行项目", m.execution_projects, "operations"],
["有风险项目", m.risk_projects, "operations"],
["本月收入", money(m.monthly_revenue), "finance"],
["本月净利", money(m.monthly_net_profit), "finance"],
["即将上线版本", 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>
${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")}
</div>
`;
renderChart(financeMonthly);
}
function renderChart(data) {
const canvas = document.querySelector("#financeChart");
if (!canvas || !window.Chart) return;
if (state.chart) state.chart.destroy();
state.chart = new Chart(canvas, {
type: "line",
data: {
labels: data.map((x) => x.month),
datasets: [
{ label: "收入", data: data.map((x) => x.revenue), borderColor: "#2563eb", tension: 0.3 },
{ label: "毛利", data: data.map((x) => x.gross_profit), borderColor: "#059669", tension: 0.3 },
{ label: "成本/费用", data: data.map((x) => x.cost_expense), borderColor: "#d97706", tension: 0.3 },
{ label: "净利", data: data.map((x) => x.net_profit), borderColor: "#7c3aed", tension: 0.3 },
],
},
options: { responsive: true, plugins: { legend: { position: "bottom" } } },
});
}
function formHtml(fields, button) {
return `<form class="inline-form flex flex-wrap items-end gap-3" onsubmit="${button.handler}(event)">
${fields.map((f) => `<label class="grid gap-1 text-sm"><span class="font-bold text-slate-600">${f.label}</span>${f.input}</label>`).join("")}
<button class="btn btn-primary" type="submit">${button.text}</button>
</form>`;
}
async function createResource(event, resource) {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget).entries());
await api(`/api/${resource}`, { method: "POST", body: JSON.stringify({ data }) });
event.currentTarget.reset();
await load();
}
window.createSales = (event) => createResource(event, "sales");
window.createProposal = (event) => createResource(event, "proposals");
window.createOperation = (event) => createResource(event, "operations");
window.createProduct = (event) => createResource(event, "products");
window.createFinance = (event) => createResource(event, "finance");
window.switchTab = switchTab;
function renderSales() {
const rows = state.data.sales.map((x) => [x.target_customer, badge(x.priority), badge(x.status), text(x.latest_follow_up_record)]);
const salesClicks = state.data.sales.map((x) => ({ resource: "sales", id: x.id }));
document.querySelector("#sales").innerHTML = `<div class="grid gap-4">
${card(formHtml([
{ label: "目标客户", input: `<input name="target_customer" required placeholder="客户名称">` },
{ label: "优先级", input: `<select name="priority"><option>P0</option><option selected>P1</option><option>P2</option><option>P3</option></select>` },
{ label: "状态", input: `<select name="status"><option>待跟进</option><option>跟进中</option><option>方案中</option><option>商务谈判</option><option>已签约</option><option>暂缓</option><option>已丢单</option></select>` },
], { handler: "createSales", text: `<i data-lucide="plus"></i>新增客户` }), "p-4")}
${renderTable(["目标客户", "优先级", "状态", "最新跟进记录"], rows, salesClicks)}
</div>`;
}
function renderProposals() {
const categories = ["方案", "成本", "SOP", "财务流程"];
const proposalRows = state.data.proposals.map((p) => [p.customer_or_project_name, p.version, badge(p.status), p.files.length + " 个"]);
const proposalClicks = state.data.proposals.map((p) => ({ resource: "proposals", id: p.id }));
document.querySelector("#proposals").innerHTML = `<div class="grid gap-4">
${card(formHtml([
{ label: "客户/项目", input: `<input name="customer_or_project_name" required placeholder="如:信达生物">` },
{ label: "版本号", input: `<input name="version" required placeholder="v1.0">` },
{ label: "状态", input: `<select name="status"><option>草稿</option><option></option><option selected></option><option></option><option></option><option></option></select>` },
], { handler: "createProposal", text: "新增版本" }), "p-4")}
${renderTable(["客户/项目", "版本号", "状态", "文件数"], proposalRows, proposalClicks)}
</div>`;
}
function fileGroup(module, ownerId, version, category, files) {
return `<div class="rounded-md border border-slate-200 px-3 py-2">
<div class="flex items-center justify-between gap-3"><p class="text-[13px] font-semibold text-slate-800">${category}</p><label class="inline-flex cursor-pointer items-center gap-1 rounded-md border border-slate-200 px-2 py-1 text-[12px] font-medium text-slate-600 hover:bg-slate-50"><i data-lucide="upload"></i>上传<input class="hidden" type="file" onchange="uploadFile(event,'${module}',${ownerId},'${version}','${category}')"></label></div>
<div class="mt-2 grid gap-1.5">${files.length ? files.map(fileItem).join("") : `<p class="text-[12px] text-slate-400">暂无文件</p>`}</div>
</div>`;
}
function fileItem(file) {
return `<div class="flex items-center justify-between gap-2 rounded-md bg-slate-50 px-2 py-1.5 text-[13px]"><div class="min-w-0 flex-1"><p class="truncate font-medium text-slate-800">${file.file_name}</p><div class="mt-0.5 flex gap-3"><a class="file-link inline-flex items-center gap-1" target="_blank" href="/api/files/${file.id}/content?inline=true"><i data-lucide="eye"></i>预览</a><a class="file-link inline-flex items-center gap-1 text-slate-600" href="/api/files/${file.id}/content?inline=false"><i data-lucide="download"></i>下载</a></div></div><button class="btn btn-ghost btn-sm text-red-600" onclick="deleteFile(${file.id})" title="删除"><i data-lucide="trash-2"></i></button></div>`;
}
window.deleteFile = async (fileId) => {
if (!confirm("确认删除此文件?")) return;
await api(`/api/files/${fileId}`, { method: "DELETE" });
await load();
closeDrawer();
};
window.uploadFile = async (event, module, ownerId, version, category) => {
const file = event.target.files[0];
if (!file) return;
const form = new FormData();
form.append("module", module);
form.append("owner_id", ownerId);
form.append("owner_version", version);
form.append("file_category", category);
form.append("file", file);
await api("/api/files/upload", { method: "POST", body: form });
await load();
};
function renderOperations() {
const items = state.opFilter === "all" ? state.data.operations : state.data.operations.filter((x) => x.project_type === state.opFilter);
const opRows = items.map((x) => [`<strong>${x.project_name}</strong><p class="text-xs text-slate-500">${x.project_version}</p>`, badge(x.project_type), badge(x.project_status), text(x.current_stage || x.sop_stage), `${x.files.length}`, text(x.latest_follow_up_record)]);
const opClicks = items.map((x) => ({ resource: "operations", id: x.id }));
document.querySelector("#operations").innerHTML = `<div class="grid gap-4">
${card(formHtml([
{ label: "项目名称", input: `<input name="project_name" required>` },
{ label: "项目版本", input: `<input name="project_version" value="v1.0">` },
{ label: "项目类型", input: `<select name="project_type"><option value="opportunity">业务机会项目</option><option value="execution"></option></select>` },
{ label: "状态", input: `<input name="project_status" value="线索发现">` },
], { handler: "createOperation", text: "新增项目" }), "p-4")}
<div class="flex gap-2">${[["all","全部项目"],["opportunity","业务机会项目"],["execution","已签约执行项目"]].map(([k,v]) => `<button class="btn ${state.opFilter === k ? "btn-primary" : "btn-ghost"}" onclick="state.opFilter='${k}'; renderOperations()">${v}</button>`).join("")}</div>
${renderTable(["项目名称", "类型", "状态", "当前阶段", "交付文件", "最新跟进"], opRows, opClicks)}
</div>`;
}
function renderProducts() {
document.querySelector("#products").innerHTML = `<div class="grid gap-4">
${card(formHtml([
{ label: "产品名称", input: `<input name="product_name" required>` },
{ label: "版本号", input: `<input name="version" required>` },
{ label: "上线日期", input: `<input name="launch_date" placeholder="2026-Q3">` },
{ label: "状态", input: `<select name="status"><option>规划中</option><option></option><option></option><option></option><option>线</option><option></option><option></option></select>` },
], { handler: "createProduct", text: "新增版本" }), "p-4")}
${renderTable(["产品名称", "版本号", "版本目标", "核心功能", "上线日期", "状态"], state.data.products.map((p) => [p.product_name, p.version, text(p.version_goal), text(p.feature_list), text(p.launch_date), badge(p.status)]), state.data.products.map((p) => ({ resource: "products", id: p.id })))}
</div>`;
}
function renderFinance() {
const rows = state.data.finance.map((x) => [x.month, badge(x.record_type === "revenue" ? "收入" : "成本/费用"), x.category, money(x.amount), x.occurred_date, text(x.notes)]);
document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
${card(`<h2 class="mb-4 text-lg font-bold">收入、毛利、成本/费用、净利月度曲线</h2><canvas id="financeChart2" height="105"></canvas>`, "p-5")}
${card(formHtml([
{ label: "月份", input: `<input name="month" required placeholder="YYYY-MM" pattern="\\d{4}-\\d{2}">` },
{ label: "类型", input: `<select name="record_type"><option value="revenue">收入</option><option value="cost_expense">/</option></select>` },
{ label: "分类", input: `<input name="category" required>` },
{ label: "金额/万", input: `<input name="amount" type="number" step="0.01" required>` },
{ label: "发生日期", input: `<input name="occurred_date" type="date">` },
], { handler: "createFinance", text: "新增明细" }), "p-4")}
${renderTable(["月份", "类型", "分类", "金额", "发生日期", "备注"], rows)}
</div>`;
renderChartOn("financeChart2", state.data.financeMonthly);
}
function renderChartOn(id, data) {
const canvas = document.querySelector(`#${id}`);
if (!canvas || !window.Chart) return;
new Chart(canvas, {
type: "line",
data: {
labels: data.map((x) => x.month),
datasets: [
{ label: "收入", data: data.map((x) => x.revenue), borderColor: "#2563eb", tension: 0.3 },
{ label: "毛利", data: data.map((x) => x.gross_profit), borderColor: "#059669", tension: 0.3 },
{ label: "成本/费用", data: data.map((x) => x.cost_expense), borderColor: "#d97706", tension: 0.3 },
{ label: "净利", data: data.map((x) => x.net_profit), borderColor: "#7c3aed", tension: 0.3 },
],
},
options: { responsive: true, plugins: { legend: { position: "bottom" } } },
});
}
function drawerField(icon, label, name, value, multiline = false) {
const initialValue = text(value);
const control = multiline
? `<textarea name="${name}" rows="2" class="drawer-value drawer-textarea" data-original="${initialValue}">${initialValue}</textarea>`
: `<input name="${name}" value="${initialValue}" class="drawer-value" data-original="${initialValue}">`;
return `<div class="drawer-field">
<div class="drawer-field-label"><i data-lucide="${icon}"></i><span>${label}</span></div>
<div class="drawer-field-control">${control}</div>
</div>`;
}
function openDrawer(resource, id) {
const list = resource === "sales" ? state.data.sales : resource === "operations" ? state.data.operations : resource === "proposals" ? state.data.proposals : state.data.products;
const item = list.find((x) => x.id === id);
const drawer = document.querySelector("#drawer");
const fields = resource === "sales"
? [["target_customer","目标客户"],["priority","优先级"],["status","状态"]]
: resource === "operations"
? [["project_name","项目名称"],["project_version","项目版本"],["project_status","项目状态"],["current_stage","当前阶段"],["target_customer","目标客户"],["customer_need","客户需求"],["expected_contract_amount","预计签约金额"],["expected_sign_date","预计签约时间"],["sign_probability","签约概率"],["sop_stage","SOP 阶段"],["execution_progress","执行进度"],["current_deliverable","当前交付物"],["risks","风险与阻塞"],["next_action","下一步动作"]]
: resource === "proposals"
? [["customer_or_project_name","客户/项目"],["version","版本号"],["description","版本说明"],["created_date","创建日期"],["status","状态"]]
: [["product_name","产品名称"],["version","版本号"],["version_goal","版本目标"],["feature_list","核心功能清单"],["launch_date","上线日期"],["status","当前状态"],["notes","备注"]];
const fieldIcons = {
target_customer: "user", priority: "flag", status: "circle-dot",
project_name: "briefcase-business", project_version: "git-branch", project_status: "circle-dot", current_stage: "map-pin",
customer_need: "file-text", expected_contract_amount: "banknote", expected_sign_date: "calendar",
sign_probability: "percent", sop_stage: "list-checks", execution_progress: "activity",
current_deliverable: "package", risks: "alert-triangle", next_action: "arrow-right",
product_name: "box", version: "tag", version_goal: "target", feature_list: "list",
launch_date: "calendar", notes: "sticky-note"
};
const multilineFields = ["customer_need", "current_deliverable", "risks", "next_action", "version_goal", "feature_list", "notes"];
const followupTarget = resource === "sales" ? "sales" : resource === "proposals" ? "proposal" : resource === "operations" ? "operation" : resource === "products" ? "product" : "";
const title = item.target_customer || item.project_name || item.customer_or_project_name || 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><button class="btn btn-ghost btn-sm" onclick="closeDrawer()">关闭</button></div><div class="grid gap-5 p-5">
<section>
<h3 class="drawer-section-title">属性</h3>
<form id="drawerForm" class="drawer-fields">${fields.map(([key,label]) => drawerField(fieldIcons[key] || "circle", label, key, item[key], multilineFields.includes(key))).join("")}</form>
</section>
${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>
<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="comment-toolbar">
<span class="comment-hint">支持 Markdown 格式</span>
<button class="btn btn-primary btn-sm comment-submit" type="submit">评论</button>
</div>
</form>
</section>` : ""}
</div></div>`;
drawer.classList.add("open");
bindDrawerAutosave(resource, item.id, item);
if (window.lucide) window.lucide.createIcons();
}
function setDrawerSaveStatus(message, tone = "muted") {
const el = document.querySelector("#drawerSaveStatus");
if (!el) return;
el.textContent = message;
el.dataset.tone = tone;
}
function bindDrawerAutosave(resource, id, item) {
document.querySelectorAll("#drawerForm .drawer-value").forEach((field) => {
field.addEventListener("keydown", (event) => {
if (event.key === "Enter" && field.tagName !== "TEXTAREA") field.blur();
});
field.addEventListener("blur", async () => {
const value = field.value;
if (value === field.dataset.original) return;
const previous = field.dataset.original;
field.dataset.original = value;
setDrawerSaveStatus("保存中…");
try {
await api(`/api/${resource}/${id}`, { method: "PUT", body: JSON.stringify({ data: { [field.name]: value } }) });
item[field.name] = value;
const titleValue = item.target_customer || item.project_name || item.customer_or_project_name || item.product_name;
const titleEl = document.querySelector(".drawer-title");
if (titleEl) titleEl.textContent = titleValue;
render();
setDrawerSaveStatus("已保存", "success");
setTimeout(() => setDrawerSaveStatus(""), 1200);
} catch (error) {
field.dataset.original = previous;
setDrawerSaveStatus("保存失败", "danger");
alert(`自动保存失败:${error.message}`);
}
});
});
}
window.openDrawer = openDrawer;
window.closeDrawer = () => document.querySelector("#drawer").classList.remove("open");
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 button = form.querySelector(".comment-submit");
button.disabled = true;
button.textContent = "发送中…";
await api(`/api/followups/${targetType}/${targetId}`, { method: "POST", body: JSON.stringify({ data: { content } }) });
await load();
openDrawer(resource, targetId);
};
window.deleteFollowup = async (event, followupId, resource, targetId) => {
event.stopPropagation();
if (!confirm("确认删除这条评论?")) return;
await api(`/api/followups/${followupId}`, { method: "DELETE" });
await load();
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);
});
document.querySelector("#refreshBtn").addEventListener("click", load);
load().catch((error) => {
document.querySelector("main").innerHTML = `<section class="card p-6 text-red-700">加载失败:${error.message}</section>`;
});

441
static/styles.css Normal file
View File

@@ -0,0 +1,441 @@
body {
min-width: 1180px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
}
.tabs {
display: flex;
gap: 4px;
}
.tabs button {
align-items: center;
border-bottom: 2px solid transparent;
color: #64748b;
display: inline-flex;
font-size: 14px;
font-weight: 600;
gap: 8px;
padding: 14px 16px;
}
.tabs button.active {
border-bottom-color: #1d4ed8;
color: #1d4ed8;
}
.panel {
display: none;
}
.panel.active {
display: block;
}
.card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.metric-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
text-align: left;
transition: border-color 0.2s ease;
}
.metric-card:hover {
border-color: #93c5fd;
}
.badge {
border-radius: 999px;
display: inline-flex;
font-size: 12px;
font-weight: 700;
line-height: 1;
padding: 6px 8px;
}
.badge-red { background: #fef2f2; color: #b91c1c; }
.badge-amber { background: #fffbeb; color: #b45309; }
.badge-green { background: #ecfdf5; color: #047857; }
.badge-blue { background: #eff6ff; color: #1d4ed8; }
.badge-slate { background: #f1f5f9; color: #475569; }
.btn {
align-items: center;
border-radius: 6px;
display: inline-flex;
font-size: 14px;
font-weight: 700;
gap: 8px;
justify-content: center;
min-height: 38px;
padding: 8px 12px;
}
[data-lucide] {
height: 16px;
stroke-width: 2;
width: 16px;
}
.btn-primary { background: #1d4ed8; color: white; }
.btn-primary:hover { background: #1e40af; }
.btn-ghost { background: white; border: 1px solid #e2e8f0; color: #334155; }
.btn-ghost:hover { background: #f8fafc; }
input,
select,
textarea {
border: 1px solid #cbd5e1;
border-radius: 6px;
min-height: 38px;
padding: 8px 10px;
}
textarea {
min-height: 96px;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border-bottom: 1px solid #e2e8f0;
padding: 12px 14px;
text-align: left;
vertical-align: top;
}
th {
background: #f1f5f9;
color: #475569;
font-size: 13px;
font-weight: 800;
}
td {
font-size: 14px;
}
.btn-sm {
min-height: 30px;
padding: 5px 10px;
font-size: 12px;
}
.drawer {
background: rgba(15, 23, 42, 0.28);
display: none;
inset: 0;
justify-content: flex-end;
position: fixed;
z-index: 50;
}
.drawer.open {
display: flex;
}
.drawer-panel {
background: white;
box-shadow: -18px 0 45px rgba(15, 23, 42, 0.14);
height: 100vh;
overflow-y: auto;
width: 560px;
}
.file-link {
color: #1d4ed8;
font-size: 12px;
font-weight: 700;
}
.drawer-section-title {
color: #334155;
font-size: 13px;
font-weight: 700;
margin-bottom: 8px;
}
.drawer-fields {
display: grid;
gap: 1px;
}
.drawer-field {
align-items: start;
display: grid;
gap: 12px;
grid-template-columns: 148px minmax(0, 1fr);
padding: 5px 0;
}
.drawer-field-label {
align-items: center;
color: #64748b;
display: flex;
font-size: 13px;
font-weight: 500;
gap: 8px;
min-height: 30px;
}
.drawer-field-label [data-lucide] {
color: #94a3b8;
height: 15px;
width: 15px;
}
.drawer-field-control {
min-width: 0;
}
.drawer-value {
background: transparent;
border: 1px solid transparent;
border-radius: 5px;
color: #0f172a;
font-size: 13px;
font-weight: 500;
min-height: 30px;
padding: 4px 8px;
width: 100%;
}
.drawer-value:hover {
background: #f8fafc;
border-color: #e2e8f0;
}
.drawer-value:focus {
background: white;
border-color: #60a5fa;
outline: none;
}
.drawer-textarea {
line-height: 1.45;
min-height: 54px;
resize: vertical;
}
.activity-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 7px;
display: flex;
font-size: 13px;
gap: 9px;
padding: 9px;
}
.activity-icon {
align-items: center;
background: #dbeafe;
border-radius: 999px;
color: #1d4ed8;
display: flex;
flex: 0 0 auto;
height: 24px;
justify-content: center;
width: 24px;
}
.activity-icon [data-lucide] {
height: 13px;
width: 13px;
}
.inline-form input,
.inline-form select {
height: 40px;
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 8px 10px;
font-size: 14px;
background: white;
min-width: 120px;
}
.inline-form input:focus,
.inline-form select:focus {
border-color: #3b82f6;
outline: none;
}
.clickable-row {
cursor: pointer;
transition: background 0.15s;
}
.clickable-row:hover {
background: #f1f5f9;
}
.save-status {
color: #94a3b8;
font-size: 12px;
font-weight: 500;
}
.save-status[data-tone="success"] {
color: #059669;
}
.save-status[data-tone="danger"] {
color: #dc2626;
}
.comment-box {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
background: white;
}
.comment-box:focus-within {
border-color: #93c5fd;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.08);
}
/* Trix editor inside comment box */
.comment-trix {
min-height: 80px;
}
.comment-trix trix-editor {
border: 0;
border-radius: 0;
font-size: 13px;
line-height: 1.55;
min-height: 80px;
padding: 10px 12px;
outline: none;
}
.comment-trix trix-toolbar {
border: 0;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
padding: 6px 6px;
}
.comment-trix trix-toolbar .trix-button {
border-radius: 4px;
padding: 3px 5px;
font-size: 12px;
background: transparent;
border-color: transparent;
}
.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;
}
.comment-toolbar {
align-items: center;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
padding: 7px 8px;
}
.comment-hint {
color: #94a3b8;
font-size: 12px;
}
.toolbar-icon {
border-radius: 4px;
cursor: pointer;
padding: 3px;
transition: background 0.15s;
}
.toolbar-icon:hover {
background: #e2e8f0;
color: #1e293b;
}
.btn:disabled,
.btn:disabled:hover {
background: #e2e8f0;
border-color: #e2e8f0;
color: #94a3b8;
cursor: not-allowed;
}
.activity-delete {
align-items: center;
border-radius: 5px;
color: #94a3b8;
display: inline-flex;
flex: 0 0 auto;
height: 26px;
justify-content: center;
opacity: 0;
transition: background 0.15s, color 0.15s, opacity 0.15s;
width: 26px;
}
.activity-item:hover .activity-delete {
opacity: 1;
}
.activity-delete:hover {
background: #fee2e2;
color: #dc2626;
}
.activity-delete [data-lucide] {
height: 14px;
width: 14px;
}
/* Trix content in activity items */
.activity-item .trix-content {
font-size: 13px;
line-height: 1.55;
}
.activity-item .trix-content div {
font-size: 13px;
}
.activity-item .trix-content strong {
font-weight: 600;
}
.activity-item .trix-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 .trix-content blockquote {
border-left: 3px solid #e2e8f0;
padding-left: 10px;
color: #64748b;
margin: 4px 0;
}

59
templates/index.html Normal file
View File

@@ -0,0 +1,59 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>科普慰心斋OPC 工作台</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
50: '#eff6ff',
600: '#2563eb',
700: '#1d4ed8'
}
}
}
}
}
</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://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">
<div>
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager · 单用户 · 单项目</p>
<h1 class="mt-1 text-2xl font-semibold">科普慰心斋OPC 工作台</h1>
</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>
<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="sales"><i data-lucide="briefcase-business"></i>销售管理</button>
<button data-tab="proposals"><i data-lucide="file-text"></i>业务方案</button>
<button data-tab="operations"><i data-lucide="activity"></i>运营管理</button>
<button data-tab="products"><i data-lucide="package"></i>产品研发</button>
<button data-tab="finance"><i data-lucide="wallet-cards"></i>财务管理</button>
</nav>
<main class="px-8 py-6">
<section id="home" class="panel active"></section>
<section id="sales" class="panel"></section>
<section id="proposals" class="panel"></section>
<section id="operations" class="panel"></section>
<section id="products" class="panel"></section>
<section id="finance" class="panel"></section>
</main>
<aside id="drawer" class="drawer" aria-hidden="true"></aside>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>