Compare commits
7 Commits
v1.1.0-bet
...
aaa213a765
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa213a765 | ||
|
|
207629a9bb | ||
|
|
361359ee32 | ||
|
|
25da1453be | ||
|
|
39f2b679a1 | ||
|
|
5f9a92b24d | ||
|
|
b6dd913275 |
91
.gitea/workflows/deploy.yml
Normal file
91
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,91 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: prod-deploy
|
||||
env:
|
||||
DEPLOY_BASE: /opt/opc-manager
|
||||
REPO_URL: https://qiukai:${{ secrets.DEPLOY_TOKEN }}@git.qiukai.me/qiukai/opc-manager.git
|
||||
SERVICE_NAME: opc-manager
|
||||
steps:
|
||||
- name: Clone and deploy
|
||||
run: |
|
||||
set -e
|
||||
RELEASE_ID="${{ github.sha }}"
|
||||
RELEASE_DIR="${DEPLOY_BASE}/releases/${RELEASE_ID}"
|
||||
CLONE_DIR="/tmp/opc-deploy-${RELEASE_ID}"
|
||||
|
||||
echo "=== 1. Clone repository ==="
|
||||
rm -rf "${CLONE_DIR}"
|
||||
git clone --depth 1 --branch main "${REPO_URL}" "${CLONE_DIR}"
|
||||
|
||||
echo "=== 2. Prepare release directory ==="
|
||||
rm -rf "${RELEASE_DIR}"
|
||||
mkdir -p "${RELEASE_DIR}"
|
||||
|
||||
# Copy repo content to release dir (exclude .git, .env, venv, data)
|
||||
rsync -a --exclude='.git' \
|
||||
--exclude='.env' \
|
||||
--exclude='.env.local' \
|
||||
--exclude='.venv' \
|
||||
--exclude='data/uploads' \
|
||||
--exclude='data/opc.sqlite' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='.gitea' \
|
||||
"${CLONE_DIR}/" "${RELEASE_DIR}/"
|
||||
|
||||
echo "=== 3. Link shared resources ==="
|
||||
mkdir -p "${RELEASE_DIR}/data"
|
||||
# .env from shared dir (not in git)
|
||||
ln -sfn "${DEPLOY_BASE}/shared/.env" "${RELEASE_DIR}/.env"
|
||||
# uploads directory from shared (persist across releases)
|
||||
mkdir -p "${DEPLOY_BASE}/shared/uploads"
|
||||
ln -sfn "${DEPLOY_BASE}/shared/uploads" "${RELEASE_DIR}/data/uploads"
|
||||
|
||||
echo "=== 4. Setup Python venv ==="
|
||||
cd "${RELEASE_DIR}"
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
echo "=== 5. Restart service ==="
|
||||
# Update WorkingDirectory in service via symlink approach
|
||||
# The systemd service points to /opt/opc-manager/current
|
||||
ln -sfn "${RELEASE_DIR}" "${DEPLOY_BASE}/current"
|
||||
systemctl restart "${SERVICE_NAME}"
|
||||
sleep 3
|
||||
|
||||
echo "=== 6. Health check ==="
|
||||
for i in 1 2 3 4 5; do
|
||||
if curl -fsS http://127.0.0.1:5177/api/health >/dev/null 2>&1; then
|
||||
echo "Health check passed"
|
||||
break
|
||||
fi
|
||||
echo "Attempt $i: waiting for service..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Final verify
|
||||
if ! curl -fsS http://127.0.0.1:5177/api/health >/dev/null 2>&1; then
|
||||
echo "ERROR: Health check failed after 5 attempts"
|
||||
echo "Rolling back to previous release..."
|
||||
PREV=$(ls -t "${DEPLOY_BASE}/releases" | sed -n '2p')
|
||||
if [ -n "${PREV}" ]; then
|
||||
ln -sfn "${DEPLOY_BASE}/releases/${PREV}" "${DEPLOY_BASE}/current"
|
||||
systemctl restart "${SERVICE_NAME}"
|
||||
echo "Rolled back to ${PREV}"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== 7. Cleanup old releases ==="
|
||||
find "${DEPLOY_BASE}/releases" -mindepth 1 -maxdepth 1 -type d | sort | head -n -5 | xargs -r rm -rf
|
||||
|
||||
echo "=== 8. Cleanup temp ==="
|
||||
rm -rf "${CLONE_DIR}"
|
||||
|
||||
echo "=== Deploy complete: ${RELEASE_ID} ==="
|
||||
@@ -53,3 +53,4 @@ curl http://127.0.0.1:5177/api/health
|
||||
- 产品:慰心斋产品路线图中的 5 个产品版本
|
||||
- 财务:首版财务样例和原财务 manager 合并方向
|
||||
|
||||
# test trigger
|
||||
|
||||
@@ -685,7 +685,7 @@ def attach_common(conn, resource, items):
|
||||
def monthly_finance(conn, tenant="科普·无界"):
|
||||
months = [f"2026-{m:02d}" for m in range(1, 13)]
|
||||
pfs = rows(conn,
|
||||
"SELECT sign_amount, sign_month, status, budget_data FROM project_finances WHERE tenant=?",
|
||||
"SELECT sign_amount, sign_month, status, budget_data FROM project_finances WHERE tenant=? AND status='已签约'",
|
||||
[tenant])
|
||||
|
||||
# 预解析 budget_data:{pf_index: {month_key: {rev, gross, payment, cost}}}
|
||||
@@ -722,7 +722,7 @@ def monthly_finance(conn, tenant="科普·无界"):
|
||||
data.append({
|
||||
"month": month, "revenue": revenue,
|
||||
"labor": 0, "expense": 0, "purchase": 0,
|
||||
"net_profit": gross,
|
||||
"gross": gross,
|
||||
"sign": sign, "payment": payment, "cost": cost,
|
||||
})
|
||||
return data
|
||||
@@ -777,22 +777,33 @@ def bootstrap():
|
||||
total += float(b.get(field) or 0)
|
||||
return total
|
||||
|
||||
# 本季度月份范围(Q1=1-3, Q2=4-6, Q3=7-9, Q4=10-12),基于当前月
|
||||
_now_month = date.today().month
|
||||
_q_start = ((_now_month - 1) // 3) * 3 + 1
|
||||
_q_range = range(_q_start, _q_start + 3)
|
||||
rev_annual = sum_budget("rev", range(1, 13))
|
||||
gross_annual = sum_budget("gross", range(1, 13))
|
||||
rev_q2 = sum_budget("rev", range(4, 7))
|
||||
gross_q2 = sum_budget("gross", range(4, 7))
|
||||
rev_month = sum_budget("rev", [6])
|
||||
gross_month = sum_budget("gross", [6])
|
||||
rev_q2 = sum_budget("rev", _q_range)
|
||||
gross_q2 = sum_budget("gross", _q_range)
|
||||
rev_month = sum_budget("rev", [_now_month])
|
||||
gross_month = sum_budget("gross", [_now_month])
|
||||
payment_annual = sum_budget("payment", range(1, 13))
|
||||
cost_annual = sum_budget("cost", range(1, 13))
|
||||
payment_q2 = sum_budget("payment", _q_range)
|
||||
cost_q2 = sum_budget("cost", _q_range)
|
||||
payment_month = sum_budget("payment", [_now_month])
|
||||
cost_month = sum_budget("cost", [_now_month])
|
||||
# Contract aggregates — from project_finances (经营管理项目)
|
||||
def pf_status_sum(status):
|
||||
return sum(x["sign_amount"] or 0 for x in pfs if x["status"] == status)
|
||||
signed_amount = pf_status_sum("已签约")
|
||||
# 年度签约 = 所有已签约项目 2026 年的签约金额
|
||||
signed_annual = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约")
|
||||
# Q2 签约 = 签约月份在 2026-04~2026-06 的已签约项目
|
||||
signed_q2 = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in ["2026-04","2026-05","2026-06"])
|
||||
# 本月签约 = 签约月份为 2026-06 的已签约项目
|
||||
signed_month = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "") == "2026-06")
|
||||
# 本季度签约 = 签约月份在当前季度的已签约项目
|
||||
_q_months = [f"2026-{m:02d}" for m in _q_range]
|
||||
signed_q2 = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in _q_months)
|
||||
# 本月签约 = 签约月份为当月的已签约项目
|
||||
signed_month = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] == f"2026-{_now_month:02d}")
|
||||
pipeline_amount = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_status"] not in ["已签约","已丢单","已归档","已完成"])
|
||||
signed_not_executed = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_type"] == "execution" and x["execution_progress"] < 100)
|
||||
summary = {
|
||||
@@ -819,6 +830,12 @@ def bootstrap():
|
||||
"revenue_q2": rev_q2,
|
||||
"gross_annual": gross_annual,
|
||||
"gross_q2": gross_q2,
|
||||
"payment_annual": payment_annual,
|
||||
"payment_q2": payment_q2,
|
||||
"payment_month": payment_month,
|
||||
"cost_annual": cost_annual,
|
||||
"cost_q2": cost_q2,
|
||||
"cost_month": cost_month,
|
||||
"signed_not_executed": signed_not_executed,
|
||||
},
|
||||
"recent": q("SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 8", tenant),
|
||||
@@ -1029,7 +1046,7 @@ def delete_file(file_id):
|
||||
|
||||
@app.route("/api/health")
|
||||
def health():
|
||||
return jsonify({"ok": True, "db": str(DB_PATH)})
|
||||
return jsonify({"ok": True, "service": "opc-manager"})
|
||||
|
||||
|
||||
init_db()
|
||||
|
||||
193
deploy/README.md
Normal file
193
deploy/README.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# OPC-Manager 自动化部署指南
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
开发者 push main → Gitea 仓库 (git.qiukai.me)
|
||||
↓
|
||||
Gitea Actions 触发
|
||||
↓
|
||||
Runner(跑在业务服务器 82.157.208.197 上)
|
||||
↓
|
||||
git clone → rsync 到 release 目录
|
||||
↓
|
||||
创建 venv → pip install → systemctl restart
|
||||
↓
|
||||
健康检查 → 切换 current 软链 → 清理旧版本
|
||||
```
|
||||
|
||||
## 服务器目录结构
|
||||
|
||||
```
|
||||
/opt/opc-manager/
|
||||
├── releases/
|
||||
│ ├── abc1234/ ← 本次发布(commit sha)
|
||||
│ ├── def5678/ ← 上次发布
|
||||
│ └── ... ← 保留最近 5 个
|
||||
├── shared/
|
||||
│ ├── .env ← 环境变量(不进 git,持久化)
|
||||
│ └── uploads/ ← 上传的文件(持久化,跨版本共享)
|
||||
└── current → releases/abc1234 ← 软链,指向当前生效版本
|
||||
```
|
||||
|
||||
## 一次性准备(在业务服务器上执行)
|
||||
|
||||
### 1. 安装 Gitea Actions Runner
|
||||
|
||||
```bash
|
||||
# 下载 act_runner
|
||||
cd /opt
|
||||
wget https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64 -O act_runner
|
||||
chmod +x act_runner
|
||||
mv act_runner /usr/local/bin/
|
||||
|
||||
# 注册 runner(到 git.qiukai.me)
|
||||
# 先在 Gitea 网页:仓库设置 → Actions → Runners → New runner,获取 token
|
||||
act_runner register \
|
||||
--instance https://git.qiukai.me \
|
||||
--token <YOUR_RUNNER_TOKEN> \
|
||||
--name prod-deploy \
|
||||
--labels prod-deploy \
|
||||
--no-interactive
|
||||
|
||||
# 安装为系统服务
|
||||
act_runner daemon --config /etc/act_runner/config.yaml &
|
||||
# 或用 systemd:
|
||||
cat > /etc/systemd/system/act-runner.service <<'EOF'
|
||||
[Unit]
|
||||
Description=Gitea Actions Runner
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/act_runner daemon
|
||||
Restart=on-failure
|
||||
Environment=HOME=/root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now act-runner
|
||||
```
|
||||
|
||||
### 2. 创建部署目录结构
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/opc-manager/{releases,shared/uploads}
|
||||
```
|
||||
|
||||
### 3. 创建 .env 文件
|
||||
|
||||
```bash
|
||||
cat > /opt/opc-manager/shared/.env <<'EOF'
|
||||
SECRET_KEY=改成一串随机字符串_至少32位
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_USER=opc
|
||||
DB_PASSWORD=opc123456
|
||||
DB_NAME=opc
|
||||
FLASK_DEBUG=false
|
||||
EOF
|
||||
|
||||
chmod 600 /opt/opc-manager/shared/.env
|
||||
```
|
||||
|
||||
### 4. 安装 systemd service
|
||||
|
||||
```bash
|
||||
# 从仓库的 deploy/opc-manager.service 复制
|
||||
cat > /etc/systemd/system/opc-manager.service <<'EOF'
|
||||
[Unit]
|
||||
Description=OPC Manager (Flask)
|
||||
After=network.target mysql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/opc-manager/current
|
||||
ExecStart=/opt/opc-manager/current/.venv/bin/gunicorn --preload -w 4 -b 0.0.0.0:5177 backend.flask_app:app
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
EnvironmentFile=/opt/opc-manager/current/.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable opc-manager
|
||||
```
|
||||
|
||||
### 5. 配置 Gitea Secret
|
||||
|
||||
在 Gitea 网页操作:
|
||||
1. 进入仓库 `qiukai/opc-manager`
|
||||
2. 设置 → Actions → Secrets → New Secret
|
||||
3. Name: `DEPLOY_TOKEN`
|
||||
4. Value: 你的 Gitea Personal Access Token(需要有 repo 读权限)
|
||||
- 生成路径:头像 → 设置 → 应用 → 生成令牌
|
||||
|
||||
### 6. 首次手动部署
|
||||
|
||||
push 代码前,先手动跑一次确保目录结构正确:
|
||||
|
||||
```bash
|
||||
cd /opt/opc-manager
|
||||
git clone --depth 1 --branch main https://git.qiukai.me/qiukai/opc-manager.git /tmp/opc-init
|
||||
RELEASE_DIR=/opt/opc-manager/releases/initial
|
||||
mkdir -p "${RELEASE_DIR}"
|
||||
rsync -a --exclude='.git' --exclude='.env' --exclude='.venv' /tmp/opc-init/ "${RELEASE_DIR}/"
|
||||
ln -sfn /opt/opc-manager/shared/.env "${RELEASE_DIR}/.env"
|
||||
ln -sfn /opt/opc-manager/shared/uploads "${RELEASE_DIR}/data/uploads"
|
||||
cd "${RELEASE_DIR}"
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
ln -sfn "${RELEASE_DIR}" /opt/opc-manager/current
|
||||
systemctl start opc-manager
|
||||
curl http://127.0.0.1:5177/api/health
|
||||
rm -rf /tmp/opc-init
|
||||
```
|
||||
|
||||
## 日常使用
|
||||
|
||||
### 发布新版本
|
||||
```bash
|
||||
# 本地
|
||||
git push origin main
|
||||
# 自动触发 Gitea Actions → 服务器自动部署
|
||||
```
|
||||
|
||||
### 查看部署状态
|
||||
```bash
|
||||
# 在 Gitea 网页:仓库 → Actions 查看部署日志
|
||||
# 或在服务器:
|
||||
systemctl status opc-manager
|
||||
ls -la /opt/opc-manager/current
|
||||
```
|
||||
|
||||
### 回滚
|
||||
```bash
|
||||
# 列出历史版本
|
||||
ls -t /opt/opc-manager/releases
|
||||
# 切换到上一个版本
|
||||
PREV=$(ls -t /opt/opc-manager/releases | sed -n '2p')
|
||||
ln -sfn "/opt/opc-manager/releases/${PREV}" /opt/opc-manager/current
|
||||
systemctl restart opc-manager
|
||||
```
|
||||
|
||||
## Nginx 反代(可选)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name opc.yxcowork.vip;
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5177;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
14
deploy/opc-manager.service
Normal file
14
deploy/opc-manager.service
Normal file
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=OPC Manager (Flask)
|
||||
After=network.target mysql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/opc-manager/current
|
||||
ExecStart=/opt/opc-manager/current/.venv/bin/gunicorn --preload -w 4 -b 0.0.0.0:5177 backend.flask_app:app
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
EnvironmentFile=/opt/opc-manager/current/.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
82
deploy/服务器配置任务提示词.md
Normal file
82
deploy/服务器配置任务提示词.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# OPC-Manager 自动化部署配置任务
|
||||
|
||||
## 背景
|
||||
OPC-Manager 项目(Flask + MySQL,端口 5177)需要配置 Gitea Actions 自动化部署。参考 SalesManager 的模式:push 到 main 分支后自动部署到业务服务器。部署工作流文件已写好(`.gitea/workflows/deploy.yml`),需要在服务器上完成一次性准备。
|
||||
|
||||
## 服务器信息
|
||||
- 业务服务器:82.157.208.197(SSH 别名 `ssh business`)
|
||||
- Gitea 地址:https://git.qiukai.me
|
||||
- 仓库:qiukai/opc-manager
|
||||
- 服务端口:5177
|
||||
- MySQL:已安装(8.0.45,系统服务),数据库 `opc`,用户 `opc/opc123456`
|
||||
|
||||
## 需要完成的 5 个步骤
|
||||
|
||||
### 1. 安装 Gitea Actions Runner
|
||||
在业务服务器上安装 `act_runner` 并注册到 Gitea:
|
||||
- 下载地址:https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64
|
||||
- 放到 `/usr/local/bin/act_runner`
|
||||
- 注册时 name=`prod-deploy`,labels=`prod-deploy`
|
||||
- 注册 token 需要到 Gitea 网页获取:仓库 qiukai/opc-manager → 设置 → Actions → Runners → New runner
|
||||
- 安装为 systemd 服务(`/etc/systemd/system/act-runner.service`),开机自启
|
||||
|
||||
### 2. 创建部署目录结构
|
||||
```bash
|
||||
mkdir -p /opt/opc-manager/{releases,shared/uploads}
|
||||
```
|
||||
|
||||
### 3. 创建 .env 文件
|
||||
在 `/opt/opc-manager/shared/.env` 创建环境变量文件:
|
||||
```
|
||||
SECRET_KEY=随机生成32位以上字符串
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_USER=opc
|
||||
DB_PASSWORD=opc123456
|
||||
DB_NAME=opc
|
||||
FLASK_DEBUG=false
|
||||
```
|
||||
权限设为 600。
|
||||
|
||||
### 4. 安装 systemd service
|
||||
创建 `/etc/systemd/system/opc-manager.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=OPC Manager (Flask)
|
||||
After=network.target mysql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/opc-manager/current
|
||||
ExecStart=/opt/opc-manager/current/.venv/bin/gunicorn --preload -w 4 -b 0.0.0.0:5177 backend.flask_app:app
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
EnvironmentFile=/opt/opc-manager/current/.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
执行 `systemctl daemon-reload && systemctl enable opc-manager`(先不 start,等首次部署后自动启动)。
|
||||
|
||||
### 5. 配置 Gitea Secret
|
||||
在 Gitea 网页操作(需要用户在浏览器完成):
|
||||
- 进入仓库 qiukai/opc-manager → 设置 → Actions → Secrets → New Secret
|
||||
- Name: `DEPLOY_TOKEN`
|
||||
- Value: 用户的 Gitea Personal Access Token(需 repo 读权限)
|
||||
- 生成路径:头像 → 设置 → 应用 → 生成令牌
|
||||
|
||||
## 首次部署验证
|
||||
准备完成后,在本地执行一次 `git push origin main`,观察:
|
||||
1. Gitea 网页 Actions 页面是否有部署任务在运行
|
||||
2. 部署日志是否正常
|
||||
3. 部署完成后 `curl http://127.0.0.1:5177/api/health` 是否返回 `{"ok":true,"service":"opc-manager"}`
|
||||
4. `systemctl status opc-manager` 是否 active
|
||||
|
||||
## 参考文件
|
||||
项目的部署工作流在仓库的 `.gitea/workflows/deploy.yml`,systemd 模板在 `deploy/opc-manager.service`,完整说明在 `deploy/README.md`。
|
||||
|
||||
## 注意事项
|
||||
- Gitea Runner 的 token 需要用户在浏览器获取后告诉你,你无法自动获取
|
||||
- Gitea Secret (DEPLOY_TOKEN) 也需要用户在浏览器配置
|
||||
- 如果服务器没有 python3.11+,需要先安装(OPC-Manager 要求 Python 3.9+)
|
||||
- 确保服务器已安装 git、rsync、curl(一般都有)
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
flask==3.1.3
|
||||
mysql-connector-python==9.4.0
|
||||
python-dotenv==1.2.1
|
||||
werkzeug==3.1.8
|
||||
gunicorn==23.0.0
|
||||
@@ -102,12 +102,12 @@ function renderFinance() {
|
||||
|
||||
document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
|
||||
<div class="grid grid-cols-6 gap-3">
|
||||
${[["已签项目","" + signed.length],["签约金额",money(sumSign)],["流程项目","" + inContract.length],["流程金额",money(sumContract)],["待签项目","" + pending.length],["待签金额",money(sumPending)]].map(([l,v]) => `<div class="bg-white rounded-lg border border-slate-200 p-3 text-center"><p class="text-xs text-slate-500">${l}</p><p class="text-xl font-bold text-slate-800">${v}</p></div>`).join("")}
|
||||
${[["已签项目","" + signed.length,"file-sign"],["签约金额",money(sumSign),"coins"],["流程项目","" + inContract.length,"file-clock"],["流程金额",money(sumContract),"clock"],["待签项目","" + pending.length,"file-question"],["待签金额",money(sumPending),"hourglass"]].map(([l,v,icon]) => `<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${l}</span><strong class="mt-2 block text-2xl">${v}</strong></div>`).join("")}
|
||||
</div>
|
||||
<div class="grid grid-cols-5 gap-3">
|
||||
${[["本月确收",money(thisMonthRev)],["本月毛利",money(thisMonthGross)],["本月回款",money(monthPayment)],["本月费用",money(monthCost)],["本月现金流",money(monthCashflow)]].map(([l,v]) => `<div class="bg-white rounded-lg border border-slate-200 p-3 text-center"><p class="text-xs text-slate-500">${l}</p><p class="text-xl font-bold text-slate-800">${v}</p></div>`).join("")}
|
||||
${[["本月确收",money(thisMonthRev),"trending-up"],["本月毛利",money(thisMonthGross),"percent"],["本月回款",money(monthPayment),"wallet"],["本月费用",money(monthCost),"receipt"],["本月现金流",money(monthCashflow),"repeat"]].map(([l,v,icon]) => `<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${l}</span><strong class="mt-2 block text-2xl">${v}</strong></div>`).join("")}
|
||||
</div>
|
||||
<div class="flex justify-end"><button class="btn btn-primary btn-sm" onclick="openFinanceModal()"><i data-lucide="plus"></i>新增财务项目</button></div>
|
||||
<div class="flex justify-between items-center"><div class="flex items-center gap-1" id="finViewToggle"><button class="btn btn-sm ${state.finView !== 'cashflow' ? 'btn-primary' : 'btn-ghost'} p-1.5" data-view="rev" onclick="setFinView('rev')" title="确收/毛利视图"><i data-lucide="trending-up" style="width:16px;height:16px"></i></button><button class="btn btn-sm ${state.finView === 'cashflow' ? 'btn-primary' : 'btn-ghost'} p-1.5" data-view="cashflow" onclick="setFinView('cashflow')" title="回款/费用视图"><i data-lucide="wallet" style="width:16px;height:16px"></i></button></div><button class="btn btn-primary btn-sm" onclick="openFinanceModal()"><i data-lucide="plus"></i>新增财务项目</button></div>
|
||||
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-5xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
|
||||
<div class="finance-tabs">
|
||||
<button class="finance-tab active" data-tab="info" onclick="switchFinanceTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
|
||||
@@ -166,7 +166,7 @@ function renderFinance() {
|
||||
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addBudgetRow()"><i data-lucide="plus"></i>添加月份</button>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-2"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closeFinanceModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>
|
||||
${card(`<div class="flex items-center justify-between mb-3"><h3 class="font-bold text-slate-700">项目明细 <span class="text-slate-400 font-normal">(${pfs.length})</span></h3><div class="flex items-center gap-1" id="finViewToggle"><button class="btn btn-sm ${state.finView !== 'cashflow' ? 'btn-primary' : 'btn-ghost'} p-1.5" data-view="rev" onclick="setFinView('rev')" title="确收/毛利视图"><i data-lucide="trending-up" style="width:16px;height:16px"></i></button><button class="btn btn-sm ${state.finView === 'cashflow' ? 'btn-primary' : 'btn-ghost'} p-1.5" data-view="cashflow" onclick="setFinView('cashflow')" title="回款/费用视图"><i data-lucide="wallet" style="width:16px;height:16px"></i></button></div></div><div class="flex gap-2 mb-3">${[["已签约","已签约"],["流程中","流程中"],["待签约","待签约"]].map(([k,v]) => `<button class="btn btn-sm ${state.finFilter === k ? 'btn-primary' : 'btn-ghost'}" onclick="state.finFilter='${k}';renderFinance()">${v} (${pfs.filter(x=>x.status===k).length})</button>`).join("")}</div><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold">项目名称</th><th class="p-2 text-center font-semibold">类型</th><th class="p-2 text-center font-semibold">状态</th><th class="p-2 text-center font-semibold">签约月份</th><th class="p-2 text-center font-semibold">签约金额</th>${monthLabels.map(l => `<th class="p-2 text-center font-semibold">${l}<br><span class="text-xs text-slate-400">${state.finView !== 'cashflow' ? '确收/毛利' : '回款/费用'}</span></th>`).join("")}<th class="p-2 text-center font-semibold">总计<br><span class="text-xs text-slate-400">${state.finView !== 'cashflow' ? '确收/毛利' : '回款/费用'}</span></th><th class="p-2 text-center font-semibold">商务负责人</th><th class="p-2 text-center font-semibold">经营负责人</th></tr></thead><tbody>${pfs.filter(x => x.status === state.finFilter).map(renderPfRow).join("")}</tbody></table></div>`, "p-4")}
|
||||
${card(`<div class="flex items-center justify-between mb-3"><h3 class="font-bold text-slate-700">项目明细 <span class="text-slate-400 font-normal">(${pfs.length})</span></h3></div><div class="flex gap-2 mb-3">${[["已签约","已签约"],["流程中","流程中"],["待签约","待签约"]].map(([k,v]) => `<button class="btn btn-sm ${state.finFilter === k ? 'btn-primary' : 'btn-ghost'}" onclick="state.finFilter='${k}';renderFinance()">${v} (${pfs.filter(x=>x.status===k).length})</button>`).join("")}</div><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold">项目名称</th><th class="p-2 text-center font-semibold">类型</th><th class="p-2 text-center font-semibold">状态</th><th class="p-2 text-center font-semibold">签约月份</th><th class="p-2 text-center font-semibold">签约金额</th>${monthLabels.map(l => `<th class="p-2 text-center font-semibold">${l}<br><span class="text-xs text-slate-400">${state.finView !== 'cashflow' ? '确收/毛利' : '回款/费用'}</span></th>`).join("")}<th class="p-2 text-center font-semibold">总计<br><span class="text-xs text-slate-400">${state.finView !== 'cashflow' ? '确收/毛利' : '回款/费用'}</span></th><th class="p-2 text-center font-semibold">商务负责人</th><th class="p-2 text-center font-semibold">经营负责人</th></tr></thead><tbody>${pfs.filter(x => x.status === state.finFilter).map(renderPfRow).join("")}</tbody></table></div>`, "p-4")}
|
||||
</div>`;
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
|
||||
@@ -3,22 +3,33 @@
|
||||
function renderHome() {
|
||||
const { summary, financeMonthly } = state.data;
|
||||
const m = summary.metrics;
|
||||
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")} 元`;
|
||||
const rows1 = [
|
||||
["年度累计签约", money(m.signed_annual || m.signed_amount)],
|
||||
["Q2 累计签约", money(m.signed_q2 || 0)],
|
||||
["本月新增签约", money(m.signed_month || 0)],
|
||||
["年度累计", moneyInt(m.signed_annual || m.signed_amount)],
|
||||
["季度累计", moneyInt(m.signed_q2 || 0)],
|
||||
["本月新增", moneyInt(m.signed_month || 0)],
|
||||
];
|
||||
const rows2 = [
|
||||
["年度累计确收", money(m.revenue_annual)],
|
||||
["Q2 累计确收", money(m.revenue_q2)],
|
||||
["本月新增确收", money(m.monthly_revenue)],
|
||||
["年度累计", moneyInt(m.revenue_annual)],
|
||||
["季度累计", moneyInt(m.revenue_q2)],
|
||||
["本月新增", moneyInt(m.monthly_revenue)],
|
||||
];
|
||||
const rows3 = [
|
||||
["年度累计毛利", money(m.gross_annual)],
|
||||
["Q2 累计毛利", money(m.gross_q2)],
|
||||
["本月新增毛利", money(m.monthly_net_profit)],
|
||||
["年度累计", moneyInt(m.gross_annual)],
|
||||
["季度累计", moneyInt(m.gross_q2)],
|
||||
["本月新增", moneyInt(m.monthly_net_profit)],
|
||||
];
|
||||
const tblCard = (title, rows) => card(`<h3 class="text-sm font-bold text-slate-700 mb-3">${title}</h3><table class="w-full text-sm"><tbody>${rows.map(([label, value]) => `<tr class="border-b border-slate-100 last:border-0"><td class="py-2 pr-4 text-slate-500">${label}</td><td class="py-2 text-right font-semibold text-slate-800">${value}</td></tr>`).join("")}</tbody></table>`, "p-4");
|
||||
const rows4 = [
|
||||
["年度累计", moneyInt(m.payment_annual || 0)],
|
||||
["季度累计", moneyInt(m.payment_q2 || 0)],
|
||||
["本月新增", moneyInt(m.payment_month || 0)],
|
||||
];
|
||||
const rows5 = [
|
||||
["年度累计", moneyInt(m.cost_annual || 0)],
|
||||
["季度累计", moneyInt(m.cost_q2 || 0)],
|
||||
["本月新增", moneyInt(m.cost_month || 0)],
|
||||
];
|
||||
const tblCard = (title, rows) => card(`<h3 class="text-sm font-bold text-slate-700 mb-3">${title}</h3><table class="w-full text-sm"><tbody>${rows.map(([label, value]) => `<tr class="border-b border-slate-100 last:border-0"><td class="py-2 pr-4 text-slate-500">${label}</td><td class="py-2 text-right font-semibold text-slate-800">${value}</td></tr>`).join("")}</tbody></table>`, "p-4");
|
||||
document.querySelector("#home").innerHTML = `
|
||||
<div class="grid gap-5">
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
@@ -29,13 +40,13 @@ 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-3 gap-5">${tblCard("合同金额", rows1)}${tblCard("确收金额", rows2)}${tblCard("确收毛利", rows3)}</div>
|
||||
<div class="grid grid-cols-5 gap-5">${tblCard("合同金额", rows1)}${tblCard("确收金额", rows2)}${tblCard("确收毛利", rows3)}${tblCard("回款金额", rows4)}${tblCard("费用金额", rows5)}</div>
|
||||
<div class="grid grid-cols-3 gap-5">
|
||||
${card(`<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="chartSign"></canvas></div>`, "p-4")}
|
||||
${card(`<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="chartRev"></canvas></div>`, "p-4")}
|
||||
${card(`<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与费用</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="chartCash"></canvas></div>`, "p-4")}
|
||||
</div>
|
||||
${card(`<h2 class="text-lg font-bold">近期动态</h2><div class="mt-4 grid gap-2">${summary.recent.map((r) => `<div class="flex items-start justify-between rounded-md bg-slate-50 px-3 py-2 text-sm group"><span class="break-words">${r.content}</span><div class="flex items-center gap-2 flex-shrink-0 ml-2"><span class="text-xs text-slate-400">${r.followed_at}</span><button class="btn btn-ghost btn-sm text-red-400 opacity-0 group-hover:opacity-100 p-0 w-5 h-5" onclick="event.preventDefault();deleteActivity(${r.id})" title="删除动态"><i data-lucide="x" style="width:14px;height:14px"></i></button></div></div>`).join("")}</div>`, "p-5")}
|
||||
${card(`<h2 class="text-lg font-bold">近期动态</h2><div class="mt-4 grid gap-2">${summary.recent.map((r) => `<div class="flex items-start justify-between rounded-md bg-slate-50 px-3 py-2 text-sm group"><span class="break-words">${r.content}</span><div class="flex items-center gap-2 flex-shrink-0 ml-2"><span class="text-xs text-slate-400">${r.followed_at}</span><button class="btn btn-ghost btn-sm text-red-400 opacity-0 group-hover:opacity-100 p-0 w-5 h-5" onclick="event.preventDefault();deleteActivity(${r.id})" title="删除动态"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></div></div>`).join("")}</div>`, "p-5")}
|
||||
</div>
|
||||
`;
|
||||
renderCharts(financeMonthly);
|
||||
@@ -81,7 +92,7 @@ function renderCharts(data) {
|
||||
type: "line",
|
||||
data: { labels, datasets: [
|
||||
{ label: "确收", data: data.map((x) => x.revenue || 0), borderColor: "#2563eb", backgroundColor: "rgba(37,99,235,0.06)", fill: true, tension: 0.3 },
|
||||
{ label: "毛利", data: data.map((x) => x.net_profit || 0), borderColor: "#059669", backgroundColor: "rgba(5,150,105,0.06)", fill: true, tension: 0.3 },
|
||||
{ label: "毛利", data: data.map((x) => x.gross || 0), borderColor: "#059669", backgroundColor: "rgba(5,150,105,0.06)", fill: true, tension: 0.3 },
|
||||
]},
|
||||
options: baseOpts,
|
||||
});
|
||||
|
||||
@@ -197,13 +197,13 @@ function renderProjects() {
|
||||
document.querySelector("#projects").innerHTML = /*html*/`
|
||||
<div class="grid grid-cols-5 gap-3 mb-4">
|
||||
${[
|
||||
["项目总数", items.length],
|
||||
["任务总数", taskStats.total],
|
||||
["进行中", taskStats.ongoing],
|
||||
["已结束", taskStats.done],
|
||||
["未开始", taskStats.pending],
|
||||
].map(([label, value]) => `
|
||||
<div class="bg-white rounded-lg border border-slate-200 p-3 text-center"><p class="text-xs text-slate-500">${label}</p><p class="text-xl font-bold text-slate-800">${value}</p></div>
|
||||
["项目总数", items.length, "folder"],
|
||||
["任务总数", taskStats.total, "list-checks"],
|
||||
["进行中", taskStats.ongoing, "play-circle"],
|
||||
["已结束", taskStats.done, "check-circle"],
|
||||
["未开始", taskStats.pending, "circle"],
|
||||
].map(([label, value, icon]) => `
|
||||
<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></div>
|
||||
`).join("")}
|
||||
</div>
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" id="loginBtn" onclick="doLogin()">登 录</button>
|
||||
<div class="footer">默认管理员:<strong>qiukai / yxcowork2026</strong></div>
|
||||
<div class="footer">Powered by <strong>yxcowork.vip</strong></div>
|
||||
</div>
|
||||
<script>
|
||||
if (window.lucide) lucide.createIcons();
|
||||
|
||||
Reference in New Issue
Block a user