推送给招商
This commit is contained in:
commit
1f669e1a1f
1
.env
Normal file
1
.env
Normal file
@ -0,0 +1 @@
|
||||
ADMIN_TOKEN=IMC_1f6a9a9c6e6c4f69bb536f3d0d8f8c7f7b6a2e4d8f3c1a5b2e7d9c4a1f0b7e6
|
||||
3
.env.example
Normal file
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
ADMIN_TOKEN=your_admin_token_here
|
||||
# DATABASE_URL=sqlite:///data/data.db
|
||||
# TZ=Asia/Shanghai
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@ -0,0 +1,17 @@
|
||||
# 多阶段构建:前端静态 → 后端镜像
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
TZ=Asia/Shanghai
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends tzdata && \
|
||||
ln -fs /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && dpkg-reconfigure -f noninteractive tzdata && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -i https://mirrors.cloud.tencent.com/pypi/simple -r requirements.txt
|
||||
COPY backend/ ./backend/
|
||||
COPY frontend/ ./frontend/
|
||||
COPY config.json ./config.json
|
||||
VOLUME ["/app/data"]
|
||||
EXPOSE 57778
|
||||
CMD ["python", "backend/app.py"]
|
||||
54
README.md
Normal file
54
README.md
Normal file
@ -0,0 +1,54 @@
|
||||
# 自动化营业额数据推送与展示系统
|
||||
|
||||
## 快速部署(Docker)
|
||||
- 1:准备环境变量(不要提交真实口令到仓库)
|
||||
- 复制 `.env.example` 到 `.env` 并填写 `ADMIN_TOKEN`
|
||||
- 2:启动服务
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
- 3:访问入口
|
||||
- 浏览器打开 `http://<服务器IP或域名>:57778`
|
||||
|
||||
## 腾讯云环境优化
|
||||
- 使用腾讯云 PyPI 镜像:Dockerfile 已设置 `pip -i https://mirrors.cloud.tencent.com/pypi/simple`
|
||||
- 设置时区为 `Asia/Shanghai`:Dockerfile 安装并配置 `tzdata`
|
||||
- 数据持久化:`docker-compose.yml` 将宿主机 `./data` 映射为容器 `/app/data`,数据库路径 `sqlite:///data/data.db`
|
||||
|
||||
## 功能概览
|
||||
- 每日 23:00(服务器本地时区)自动生成并定版当日营业额
|
||||
- 修正接口:支持按日期修正金额,前端即时一致更新;修正过程不直接在前端展示
|
||||
- 审计与日志:所有生成/修正写入审计表,兼容追加到 `app.log`
|
||||
- 看板:今日/昨日/前日、本周(周一~昨日)、上周(周一~周日)、本月
|
||||
- 折线图:最近 7 天、本月、上月、最近 90 天;支持缩放、导出图片/CSV
|
||||
|
||||
## 项目结构
|
||||
```
|
||||
.
|
||||
├── backend/ # Flask API + APScheduler
|
||||
├── frontend/ # 单页 HTML(Tailwind+Chart.js)
|
||||
├── data/ # SQLite 数据卷(自动创建/挂载)
|
||||
├── Dockerfile # 生产镜像(已适配腾讯云)
|
||||
├── docker-compose.yml # 一键部署
|
||||
├── .env.example # 环境变量示例(不要提交真实 .env)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
- `DATABASE_URL`:数据库连接字符串(默认 `sqlite:///data/data.db`)
|
||||
- `ADMIN_TOKEN`:管理修正口令;修正时在请求头加入 `X-Admin-Token`
|
||||
- `TZ`:容器时区(默认 `Asia/Shanghai`)
|
||||
- `PORT`:应用监听端口(默认 `5000`,示例已改为 `57778`)
|
||||
|
||||
## 修正接口示例
|
||||
```bash
|
||||
curl -X PUT http://localhost:5000/api/admin/turnover \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Admin-Token: $ADMIN_TOKEN" \
|
||||
-d '{"date":"2025-12-06","amount":3123.45,"reason":"调整入账"}'
|
||||
```
|
||||
|
||||
## 生产建议
|
||||
- 建议使用反向代理(Nginx)暴露 `5000` 端口,并开启 gzip
|
||||
- 建议通过 `docker-compose` 的 `restart: unless-stopped` 保持服务稳定
|
||||
- 如需外部数据库,设置 `DATABASE_URL`(例如 PostgreSQL),并移除数据卷映射
|
||||
294
backend/app.py
Normal file
294
backend/app.py
Normal file
@ -0,0 +1,294 @@
|
||||
from flask import Flask, jsonify, request, send_from_directory, Response
|
||||
from flask import stream_with_context
|
||||
from dotenv import load_dotenv
|
||||
from flask_cors import CORS
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from tzlocal import get_localzone
|
||||
from sqlalchemy import text
|
||||
from datetime import datetime, timedelta, date
|
||||
import re
|
||||
import random
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
|
||||
load_dotenv()
|
||||
app = Flask(__name__, static_folder="../frontend", static_url_path="/static")
|
||||
CORS(app)
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv("DATABASE_URL", "sqlite:///data.db")
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
class DailyRevenue(db.Model):
|
||||
__tablename__ = 'daily_revenue'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
date = db.Column(db.Date, nullable=False, unique=True)
|
||||
amount = db.Column(db.Float, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
is_final = db.Column(db.Boolean, default=False, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
source = db.Column(db.String(20))
|
||||
note = db.Column(db.Text)
|
||||
|
||||
class AuditLog(db.Model):
|
||||
__tablename__ = 'audit_log'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
old_amount = db.Column(db.Float)
|
||||
new_amount = db.Column(db.Float)
|
||||
reason = db.Column(db.Text)
|
||||
actor = db.Column(db.String(50))
|
||||
type = db.Column(db.String(20)) # generate/correct/import_log
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
# SQLite 动态扩展列(首次添加)
|
||||
cols = [row[1] for row in db.session.execute(text('PRAGMA table_info(daily_revenue)')).fetchall()]
|
||||
if 'is_final' not in cols:
|
||||
db.session.execute(text('ALTER TABLE daily_revenue ADD COLUMN is_final INTEGER NOT NULL DEFAULT 0'))
|
||||
if 'updated_at' not in cols:
|
||||
db.session.execute(text('ALTER TABLE daily_revenue ADD COLUMN updated_at DATETIME'))
|
||||
if 'source' not in cols:
|
||||
db.session.execute(text('ALTER TABLE daily_revenue ADD COLUMN source TEXT'))
|
||||
if 'note' not in cols:
|
||||
db.session.execute(text('ALTER TABLE daily_revenue ADD COLUMN note TEXT'))
|
||||
db.session.commit()
|
||||
|
||||
def generate_mock_revenue():
|
||||
"""保持原有逻辑:生成当日模拟营业额"""
|
||||
base = random.uniform(8000, 15000)
|
||||
trend = random.uniform(0.95, 1.05)
|
||||
return round(base * trend, 2)
|
||||
|
||||
def _append_log_line(date_str: str, amount: float, shop_name: str):
|
||||
log_path = os.path.join(os.path.dirname(__file__), "..", "app.log")
|
||||
line = f"准备发送消息: 【{shop_name}】{date_str}的营业额:{amount}"
|
||||
with open(log_path, 'a', encoding='utf-8') as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
def daily_job():
|
||||
"""定时任务:在本地 23:00 生成并定版当日数据(不重复生成)"""
|
||||
cfg = load_config()
|
||||
shop_name = cfg.get("shop_name", "益选便利店")
|
||||
today = datetime.now().date()
|
||||
existing = DailyRevenue.query.filter_by(date=today).first()
|
||||
if existing:
|
||||
if not existing.is_final:
|
||||
existing.is_final = True
|
||||
existing.source = existing.source or 'generator'
|
||||
db.session.commit()
|
||||
return
|
||||
amount = generate_mock_revenue()
|
||||
rev = DailyRevenue(date=today, amount=amount, is_final=True, source='generator')
|
||||
db.session.add(rev)
|
||||
db.session.add(AuditLog(date=today, old_amount=None, new_amount=amount, reason='daily_generate', actor='system', type='generate'))
|
||||
db.session.commit()
|
||||
_append_log_line(today.isoformat(), amount, shop_name)
|
||||
|
||||
# ---- 日志解析与聚合 ----
|
||||
def load_config():
|
||||
cfg_path = os.path.join(os.path.dirname(__file__), "..", "config.json")
|
||||
try:
|
||||
with open(cfg_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {"shop_name": "益选便利店", "weekday_range": [2800, 3600], "weekend_range": [1600, 2000]}
|
||||
|
||||
def parse_app_log():
|
||||
log_path = os.path.join(os.path.dirname(__file__), "..", "app.log")
|
||||
data = {}
|
||||
if not os.path.exists(log_path):
|
||||
return data
|
||||
p1 = re.compile(r"准备发送消息: 【.*?】(\d{4}-\d{2}-\d{2})的营业额:(\d+(?:\.\d+)?)")
|
||||
p2 = re.compile(r"准备发送消息: (\d{4}-\d{2}-\d{2}):(\d+(?:\.\d+)?)")
|
||||
with open(log_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
m = p1.search(line) or p2.search(line)
|
||||
if m:
|
||||
d, amt = m.group(1), float(m.group(2))
|
||||
data[d] = amt
|
||||
return data
|
||||
|
||||
def gen_amount_for_date(d: date, cfg: dict):
|
||||
wk = d.weekday()
|
||||
lo, hi = (cfg.get("weekend_range", [1600, 2000]) if wk >= 5 else cfg.get("weekday_range", [2800, 3600]))
|
||||
return round(random.uniform(lo, hi), 2)
|
||||
|
||||
def sum_period(dates_map, start: date, end: date, cfg):
|
||||
total = 0.0
|
||||
cur = start
|
||||
while cur <= end:
|
||||
key = cur.isoformat()
|
||||
total += dates_map.get(key, gen_amount_for_date(cur, cfg))
|
||||
cur += timedelta(days=1)
|
||||
return round(total, 2)
|
||||
|
||||
def get_periods(today: date):
|
||||
# 不包含今天
|
||||
yday = today - timedelta(days=1)
|
||||
day_before = today - timedelta(days=2)
|
||||
# 本周(周一到昨日)
|
||||
monday = yday - timedelta(days=yday.weekday())
|
||||
week_start = monday
|
||||
week_end = yday
|
||||
# 上周(上周一到上周日)
|
||||
last_week_end = week_start - timedelta(days=1)
|
||||
last_week_start = last_week_end - timedelta(days=6)
|
||||
# 本月(当月1号到昨日)
|
||||
month_start = yday.replace(day=1)
|
||||
month_end = yday
|
||||
return {
|
||||
"yesterday": yday,
|
||||
"day_before": day_before,
|
||||
"this_week": (week_start, week_end),
|
||||
"last_week": (last_week_start, last_week_end),
|
||||
"this_month": (month_start, month_end),
|
||||
}
|
||||
|
||||
@app.route("/api/metrics")
|
||||
def api_metrics():
|
||||
cfg = load_config()
|
||||
shop_name = cfg.get("shop_name", "益选便利店")
|
||||
today_local = datetime.now().date()
|
||||
periods = get_periods(today_local)
|
||||
yday = periods["yesterday"]
|
||||
day_before = periods["day_before"]
|
||||
tw_s, tw_e = periods["this_week"]
|
||||
lw_s, lw_e = periods["last_week"]
|
||||
tm_s, tm_e = periods["this_month"]
|
||||
# DB 为唯一来源
|
||||
def amt_for(d: date):
|
||||
r = DailyRevenue.query.filter_by(date=d).first()
|
||||
return r.amount if (r and r.is_final) else None
|
||||
# 周区间求和:仅统计已定版日期
|
||||
def sum_final(start: date, end: date):
|
||||
cur, total = start, 0.0
|
||||
while cur <= end:
|
||||
r = DailyRevenue.query.filter_by(date=cur).first()
|
||||
if r and r.is_final:
|
||||
total += r.amount
|
||||
cur += timedelta(days=1)
|
||||
return round(total, 2)
|
||||
weekdays = ['周一','周二','周三','周四','周五','周六','周日']
|
||||
out = {
|
||||
"shop_name": shop_name,
|
||||
"server_now": datetime.now().isoformat(timespec='seconds'),
|
||||
"cutoff_hour": 23,
|
||||
"today": {"date": today_local.isoformat(), "weekday": weekdays[today_local.weekday()], "amount": amt_for(today_local)},
|
||||
"yesterday": {"date": yday.isoformat(), "amount": amt_for(yday)},
|
||||
"day_before": {"date": day_before.isoformat(), "amount": amt_for(day_before)},
|
||||
"this_week": {"start": tw_s.isoformat(), "end": tw_e.isoformat(), "total": sum_final(tw_s, tw_e)},
|
||||
"last_week": {"start": lw_s.isoformat(), "end": lw_e.isoformat(), "total": sum_final(lw_s, lw_e)},
|
||||
"this_month": {"start": tm_s.isoformat(), "end": tm_e.isoformat(), "total": sum_final(tm_s, tm_e)},
|
||||
}
|
||||
return jsonify(out)
|
||||
|
||||
@app.route("/api/series7")
|
||||
def api_series7():
|
||||
today_local = datetime.now().date()
|
||||
days = int(request.args.get('days', 7))
|
||||
days = max(7, min(days, 90))
|
||||
# 结束日期:若今日已定版则含今日,否则到昨日
|
||||
end = today_local
|
||||
r_today = DailyRevenue.query.filter_by(date=today_local).first()
|
||||
if not (r_today and r_today.is_final):
|
||||
end = today_local - timedelta(days=1)
|
||||
start = end - timedelta(days=days-1)
|
||||
cfg = load_config()
|
||||
series = []
|
||||
cur = start
|
||||
while cur <= end:
|
||||
r = DailyRevenue.query.filter_by(date=cur).first()
|
||||
if r and r.is_final:
|
||||
amt = r.amount
|
||||
est = False
|
||||
else:
|
||||
amt = gen_amount_for_date(cur, cfg)
|
||||
est = True
|
||||
series.append({"date": cur.isoformat(), "amount": amt, "estimated": est})
|
||||
cur += timedelta(days=1)
|
||||
return jsonify(series)
|
||||
|
||||
@app.route("/api/revenue")
|
||||
def api_revenue():
|
||||
"""查询历史营业额"""
|
||||
days = int(request.args.get("days", 30))
|
||||
start = datetime.utcnow().date() - timedelta(days=days)
|
||||
rows = DailyRevenue.query.filter(DailyRevenue.date >= start).order_by(DailyRevenue.date.desc()).all()
|
||||
return jsonify([{"date": r.date.isoformat(), "amount": r.amount} for r in rows])
|
||||
|
||||
@app.route("/api/export")
|
||||
def api_export():
|
||||
"""CSV导出"""
|
||||
rows = DailyRevenue.query.order_by(DailyRevenue.date.desc()).all()
|
||||
csv = "date,amount\n" + "\n".join([f"{r.date},{r.amount}" for r in rows if r.is_final])
|
||||
return csv, 200, {"Content-Type": "text/csv; charset=utf-8", "Content-Disposition": "attachment; filename=revenue.csv"}
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
"""SPA入口"""
|
||||
return send_from_directory(app.static_folder, "index.html")
|
||||
|
||||
@app.route("/admin")
|
||||
def admin_page():
|
||||
return send_from_directory(app.static_folder, "admin.html")
|
||||
|
||||
@app.route('/api/admin/turnover', methods=['PUT'])
|
||||
def admin_correct():
|
||||
token = os.getenv('ADMIN_TOKEN')
|
||||
if token and request.headers.get('X-Admin-Token') != token:
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
payload = request.get_json(force=True)
|
||||
d = datetime.strptime(payload['date'], '%Y-%m-%d').date()
|
||||
new_amt = float(payload['amount'])
|
||||
reason = payload.get('reason', 'manual_correct')
|
||||
actor = payload.get('actor', 'admin')
|
||||
r = DailyRevenue.query.filter_by(date=d).first()
|
||||
old = r.amount if r else None
|
||||
if r:
|
||||
r.amount = new_amt
|
||||
r.is_final = True
|
||||
r.source = 'correct'
|
||||
else:
|
||||
r = DailyRevenue(date=d, amount=new_amt, is_final=True, source='correct')
|
||||
db.session.add(r)
|
||||
db.session.add(AuditLog(date=d, old_amount=old, new_amount=new_amt, reason=reason, actor=actor, type='correct'))
|
||||
db.session.commit()
|
||||
_append_log_line(d.isoformat(), new_amt, load_config().get('shop_name', '益选便利店'))
|
||||
return jsonify({"ok": True})
|
||||
|
||||
def sync_log_to_db():
|
||||
"""启动时将 app.log 中缺失的数据同步到 DB(只同步今天之前)"""
|
||||
log_map = parse_app_log()
|
||||
today_local = datetime.now().date()
|
||||
for ds, amt in log_map.items():
|
||||
d = datetime.strptime(ds, '%Y-%m-%d').date()
|
||||
if d >= today_local:
|
||||
continue
|
||||
r = DailyRevenue.query.filter_by(date=d).first()
|
||||
if not r:
|
||||
db.session.add(DailyRevenue(date=d, amount=amt, is_final=True, source='import_log'))
|
||||
db.session.add(AuditLog(date=d, old_amount=None, new_amount=amt, reason='import_log', actor='system', type='import_log'))
|
||||
db.session.commit()
|
||||
|
||||
if __name__ == "__main__":
|
||||
local_tz = get_localzone()
|
||||
scheduler = BackgroundScheduler(timezone=local_tz)
|
||||
scheduler.add_job(daily_job, "cron", hour=23, minute=0)
|
||||
scheduler.start()
|
||||
with app.app_context():
|
||||
sync_log_to_db()
|
||||
app.run(host="0.0.0.0", port=int(os.getenv("PORT", "5000")))
|
||||
|
||||
@app.route('/api/events')
|
||||
def sse_events():
|
||||
def event_stream():
|
||||
while True:
|
||||
now = datetime.now()
|
||||
payload = {"type": "tick", "server_now": now.isoformat(timespec='seconds')}
|
||||
yield f"data: {json.dumps(payload)}\n\n"
|
||||
if now.hour == 23 and now.minute == 1:
|
||||
yield "data: {\"type\": \"force_refresh\"}\n\n"
|
||||
time.sleep(30)
|
||||
return Response(stream_with_context(event_stream()), mimetype='text/event-stream')
|
||||
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
Flask==2.3.3
|
||||
Flask-CORS==4.0.0
|
||||
Flask-SQLAlchemy==3.0.5
|
||||
APScheduler==3.10.4
|
||||
python-dotenv==1.0.0
|
||||
tzlocal==5.2
|
||||
tzdata==2025.1
|
||||
12
config.json
Normal file
12
config.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"webhook_url": "https://api.hiflow.tencent.com/engine/webhook/31/1869391857524076545",
|
||||
"shop_name": "益选便利店",
|
||||
"weekday_range": [
|
||||
2900,
|
||||
3600
|
||||
],
|
||||
"weekend_range": [
|
||||
1600,
|
||||
2000
|
||||
]
|
||||
}
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@ -0,0 +1,14 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
revenue:
|
||||
build: .
|
||||
ports:
|
||||
- "57778:57778"
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:///data/data.db
|
||||
- TZ=Asia/Shanghai
|
||||
- ADMIN_TOKEN=${ADMIN_TOKEN}
|
||||
- PORT=57778
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
75
docs/自动化营业额系统/DESIGN.md
Normal file
75
docs/自动化营业额系统/DESIGN.md
Normal file
@ -0,0 +1,75 @@
|
||||
# 自动化营业额数据推送与展示系统 技术架构
|
||||
|
||||
## 技术栈
|
||||
- 后端:FastAPI(Python)
|
||||
- 定时:APScheduler(内嵌)/ Celery(可选)
|
||||
- 数据库:SQLite(开发默认)/ PostgreSQL(生产)
|
||||
- 前端:React + Tailwind(或 Vue + Tailwind)
|
||||
- 部署:Docker + Docker Compose
|
||||
|
||||
## 系统架构
|
||||
```
|
||||
[Generator & Scheduler] -> [FastAPI REST] -> [DB]
|
||||
|
|
||||
-> [Web UI]
|
||||
-> [Notifier]
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
- 表:`turnover`
|
||||
- `id` INTEGER PK
|
||||
- `date` DATE UNIQUE
|
||||
- `amount` DECIMAL(12,2)
|
||||
- `created_at` TIMESTAMP
|
||||
- 表:`config`
|
||||
- `key` TEXT PK
|
||||
- `value` TEXT
|
||||
|
||||
## API 契约
|
||||
- `GET /api/turnover/today` 返回当日数据
|
||||
- `GET /api/turnover/range?start=YYYY-MM-DD&end=YYYY-MM-DD` 区间查询
|
||||
- `GET /api/turnover/export.csv` 导出 CSV
|
||||
- `POST /api/admin/recompute?date=YYYY-MM-DD` 重新生成指定日期(管理员)
|
||||
|
||||
## 定时方案
|
||||
- APScheduler `cron` 每日 `00:05` 触发生成任务
|
||||
- 任务幂等:同日重复执行将覆盖/更新当日记录
|
||||
|
||||
## 容器化
|
||||
- `backend`:FastAPI + APScheduler
|
||||
- `frontend`:静态构建产物挂载到 `nginx` 或 `vite preview`
|
||||
- `db`:SQLite 挂载卷 / 可切换 PostgreSQL 服务
|
||||
- `notifier`:邮件/Webhook 可选模块
|
||||
|
||||
## Docker Compose 示例
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "8000:8000"
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
db:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_PASSWORD: example
|
||||
volumes:
|
||||
- ./pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
```
|
||||
|
||||
## 异常与告警
|
||||
- 生成失败:记录日志并触发邮件/Webhook(启用时)
|
||||
- API 错误:统一异常处理中返回标准错误响应
|
||||
|
||||
## 安全与配置
|
||||
- `.env` 管理数据库与通知凭据
|
||||
- 简易登录:基于环境变量开关,默认关闭
|
||||
|
||||
45
docs/自动化营业额系统/PRD.md
Normal file
45
docs/自动化营业额系统/PRD.md
Normal file
@ -0,0 +1,45 @@
|
||||
# 自动化营业额数据推送与展示系统 PRD
|
||||
|
||||
## 项目概述
|
||||
- 目标:摆脱第三方平台依赖,实现每日自动生成、持久化、展示营业额数据,并通过 Docker 一键部署。
|
||||
- 使用人群:运营(查看与导出)、技术(运维与配置)。
|
||||
|
||||
## 用户角色
|
||||
- 管理员:配置任务、查看与导出数据、触发重跑、查看告警。
|
||||
- 访客:查看仪表盘与历史数据。
|
||||
|
||||
## 核心功能
|
||||
- 数据生成:保留现有假数据逻辑(可优化为可参数化),每日定时生成。
|
||||
- 数据存储:SQLite(默认)/ PostgreSQL(生产),结构化存储并持久化历史。
|
||||
- Web 展示:响应式仪表盘、趋势图、表格历史查询、区间筛选与导出。
|
||||
- 定时执行:APScheduler(内嵌)或 Celery(可选扩展)。
|
||||
- 容器化:Dockerfile + Docker Compose 一键部署。
|
||||
- 通知提醒:邮件或 Webhook(可选),失败时发送告警。
|
||||
- 访问控制:简易登录(可选),基于环境变量开关。
|
||||
|
||||
## 页面与交互
|
||||
- 首页仪表盘
|
||||
- 当日营业额、近7/30日趋势图、同比环比(基于模拟规则)。
|
||||
- 历史数据页
|
||||
- 时间区间筛选、列表与图表联动、CSV/Excel 导出。
|
||||
- 配置管理页(可选)
|
||||
- 定时规则、数据生成参数、通知渠道配置。
|
||||
|
||||
## 验收标准
|
||||
- 每日自动生成与写库,无需人工干预。
|
||||
- Web 界面在 PC 与移动端均能正常展示与操作。
|
||||
- 历史数据可查询并支持 CSV/Excel 导出。
|
||||
- Docker Compose 可一键启动全部服务并运行定时任务。
|
||||
- 失败自动告警可在启用时生效。
|
||||
|
||||
## 非功能需求
|
||||
- 可维护:配置项集中于 `.env` 与 `config.json`。
|
||||
- 可扩展:后端 API 与任务模块化,便于替换生成逻辑。
|
||||
- 安全:不提交敏感信息到代码仓库;登录开关默认关闭。
|
||||
|
||||
## 里程碑
|
||||
- M1:后端 API + 数据模型 + 定时任务(APScheduler)。
|
||||
- M2:前端响应式展示 + 区间查询 + 导出。
|
||||
- M3:Docker 化与部署文档。
|
||||
- M4:告警通知与简单登录(可选)。
|
||||
|
||||
193
frontend/admin.html
Normal file
193
frontend/admin.html
Normal file
@ -0,0 +1,193 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>数据修正 - IMC益选便利店</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #22d3ee; /* cyan-400 */
|
||||
--primary-dim: rgba(34, 211, 238, 0.1);
|
||||
--bg-dark: #0b1121;
|
||||
}
|
||||
body { background-color: var(--bg-dark); color: #e2e8f0; font-family: 'Inter', system-ui, sans-serif; }
|
||||
|
||||
/* Tech Background Grid - Toned down */
|
||||
.bg-tech-grid {
|
||||
background-size: 40px 40px;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(34, 211, 238, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(34, 211, 238, 0.03) 1px, transparent 1px);
|
||||
mask-image: radial-gradient(circle at center, black 30%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Glass Tech Panel - Toned down */
|
||||
.glass-panel {
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Typography & Effects */
|
||||
.text-glow { text-shadow: 0 0 5px rgba(34, 211, 238, 0.3); }
|
||||
.font-mono-tech { font-family: 'JetBrains Mono', 'Consolas', monospace; }
|
||||
|
||||
.btn-tech {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
color: #94a3b8;
|
||||
transition: all 0.2s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.btn-tech:hover {
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
box-shadow: 0 0 10px rgba(34, 211, 238, 0.1);
|
||||
}
|
||||
|
||||
.input-tech {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
color: #e2e8f0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.input-tech:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 1px rgba(34, 211, 238, 0.2);
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0b1121; }
|
||||
::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 3px; border: 1px solid #334155; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--primary); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased min-h-screen bg-slate-900 selection:bg-cyan-500 selection:text-white overflow-x-hidden flex items-center justify-center">
|
||||
<!-- Tech Background Layers -->
|
||||
<div class="fixed inset-0 bg-[url('https://tailwindcss.com/img/beams-home@95.jpg')] bg-cover bg-center opacity-20 pointer-events-none"></div>
|
||||
<div class="fixed inset-0 bg-tech-grid pointer-events-none z-0"></div>
|
||||
<div class="fixed inset-0 bg-gradient-to-b from-slate-900/50 via-slate-900/20 to-slate-900/80 pointer-events-none z-0"></div>
|
||||
|
||||
<div class="relative z-10 w-full max-w-md p-4">
|
||||
<div class="glass-panel p-6 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-bold tracking-wider text-white text-glow flex items-center gap-2">
|
||||
<span class="w-1.5 h-6 bg-cyan-400 rounded-full shadow-[0_0_8px_#22d3ee]"></span>
|
||||
数据修正控制台
|
||||
</h1>
|
||||
<a href="/" class="text-xs text-slate-500 hover:text-cyan-400 transition-colors font-mono">< 返回看板</a>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-400 mb-1.5 uppercase tracking-wider">日期 / DATE</label>
|
||||
<input id="date" type="date" class="input-tech w-full px-3 py-2 rounded text-sm font-mono-tech" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-400 mb-1.5 uppercase tracking-wider">金额 / AMOUNT</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-2 text-slate-500">¥</span>
|
||||
<input id="amount" type="number" step="0.01" class="input-tech w-full pl-7 pr-3 py-2 rounded text-sm font-mono-tech" placeholder="0.00" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-400 mb-1.5 uppercase tracking-wider">原因 / REASON</label>
|
||||
<input id="reason" type="text" class="input-tech w-full px-3 py-2 rounded text-sm" placeholder="例如:手动调整入账" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-400 mb-1.5 uppercase tracking-wider">管理口令 / ADMIN TOKEN</label>
|
||||
<div class="flex gap-2">
|
||||
<input id="token" type="password" class="input-tech flex-1 px-3 py-2 rounded text-sm font-mono-tech" placeholder="输入 .env 中的口令" />
|
||||
<button id="toggle" type="button" class="btn-tech px-3 py-2 rounded text-xs whitespace-nowrap">显示</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="submit" class="w-full mt-2 bg-cyan-600/20 border border-cyan-500/50 text-cyan-400 hover:bg-cyan-500/20 hover:border-cyan-400 hover:shadow-[0_0_15px_rgba(34,211,238,0.2)] transition-all duration-300 py-2.5 rounded text-sm font-bold tracking-widest uppercase">
|
||||
提交修正 / SUBMIT
|
||||
</button>
|
||||
|
||||
<div id="msg" class="text-xs mt-3 font-mono text-center min-h-[1.5em]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function post(url, data, headers){
|
||||
return fetch(url,{method:'PUT',headers:Object.assign({'Content-Type':'application/json'},headers||{}),body:JSON.stringify(data)});
|
||||
}
|
||||
|
||||
// Auto fill today
|
||||
document.getElementById('date').valueAsDate = new Date();
|
||||
|
||||
document.getElementById('toggle').addEventListener('click', (e)=>{
|
||||
const el = document.getElementById('token');
|
||||
const btn = e.target;
|
||||
if (el.type === 'password') {
|
||||
el.type = 'text';
|
||||
btn.textContent = '隐藏';
|
||||
btn.classList.add('text-cyan-300');
|
||||
} else {
|
||||
el.type = 'password';
|
||||
btn.textContent = '显示';
|
||||
btn.classList.remove('text-cyan-300');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('submit').addEventListener('click', async ()=>{
|
||||
const btn = document.getElementById('submit');
|
||||
const date = document.getElementById('date').value;
|
||||
const amount = document.getElementById('amount').value;
|
||||
const reason = document.getElementById('reason').value || 'manual_correct';
|
||||
const token = document.getElementById('token').value;
|
||||
const msg = document.getElementById('msg');
|
||||
|
||||
if(!date || !amount || !token){
|
||||
msg.textContent = 'ERROR: 请填写完整信息 (Missing fields)';
|
||||
msg.className='text-red-400 text-xs mt-3 font-mono text-center min-h-[1.5em]';
|
||||
return;
|
||||
}
|
||||
if(isNaN(Number(amount))){
|
||||
msg.textContent = 'ERROR: 金额格式错误 (Invalid Amount)';
|
||||
msg.className='text-red-400 text-xs mt-3 font-mono text-center min-h-[1.5em]';
|
||||
return;
|
||||
}
|
||||
|
||||
// Loading state
|
||||
const originText = btn.innerHTML;
|
||||
btn.textContent = 'PROCESSING...';
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
|
||||
try{
|
||||
const res = await post('/api/admin/turnover',{date,amount:Number(amount),reason},{'X-Admin-Token':token});
|
||||
const ok = res.ok;
|
||||
if(ok){
|
||||
msg.textContent = 'SUCCESS: 数据已修正,面板将同步更新';
|
||||
msg.className='text-green-400 text-xs mt-3 font-mono text-center min-h-[1.5em]';
|
||||
// Reset amount
|
||||
document.getElementById('amount').value = '';
|
||||
}
|
||||
else {
|
||||
const t = await res.text();
|
||||
msg.textContent = 'FAILED: ' + t;
|
||||
msg.className='text-red-400 text-xs mt-3 font-mono text-center min-h-[1.5em]';
|
||||
}
|
||||
}catch(e){
|
||||
msg.textContent = 'NETWORK ERROR: '+e;
|
||||
msg.className='text-red-400 text-xs mt-3 font-mono text-center min-h-[1.5em]';
|
||||
} finally {
|
||||
btn.innerHTML = originText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
602
frontend/index.html
Normal file
602
frontend/index.html
Normal file
@ -0,0 +1,602 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>IMC益选便利店营业额数据看板</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-zoom/2.2.0/chartjs-plugin-zoom.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #22d3ee; /* cyan-400 */
|
||||
--primary-dim: rgba(34, 211, 238, 0.1);
|
||||
--bg-dark: #0b1121;
|
||||
}
|
||||
body { background-color: var(--bg-dark); color: #e2e8f0; font-family: 'Inter', system-ui, sans-serif; }
|
||||
|
||||
/* Tech Background Grid - Toned down */
|
||||
.bg-tech-grid {
|
||||
background-size: 40px 40px;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(34, 211, 238, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(34, 211, 238, 0.03) 1px, transparent 1px);
|
||||
mask-image: radial-gradient(circle at center, black 30%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Glass Tech Panel - Toned down */
|
||||
.glass-panel {
|
||||
background: rgba(15, 23, 42, 0.85); /* More opaque for readability */
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1); /* Subtle white border */
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Simplified Hover */
|
||||
.glass-panel:hover {
|
||||
border-color: rgba(34, 211, 238, 0.3);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Typography & Effects - Cleaner */
|
||||
.text-glow { text-shadow: 0 0 5px rgba(34, 211, 238, 0.3); }
|
||||
.font-mono-tech { font-family: 'JetBrains Mono', 'Consolas', monospace; }
|
||||
|
||||
.btn-tech {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
color: #94a3b8;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-tech:hover {
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
.btn-tech.active {
|
||||
background: var(--primary);
|
||||
color: #0f172a;
|
||||
border-color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chip-tech {
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
color: #94a3b8;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0b1121; }
|
||||
::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 3px; border: 1px solid #334155; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--primary); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased min-h-screen bg-slate-900 selection:bg-cyan-500 selection:text-white overflow-x-hidden">
|
||||
<!-- Tech Background Layers -->
|
||||
<div class="fixed inset-0 bg-[url('https://tailwindcss.com/img/beams-home@95.jpg')] bg-cover bg-center opacity-20 pointer-events-none"></div>
|
||||
<div class="fixed inset-0 bg-tech-grid pointer-events-none z-0"></div>
|
||||
<div class="fixed inset-0 bg-gradient-to-b from-slate-900/50 via-slate-900/20 to-slate-900/80 pointer-events-none z-0"></div>
|
||||
|
||||
<div id="app" class="relative z-10 max-w-5xl mx-auto p-3 sm:p-4">
|
||||
<header class="sticky top-2 z-50 mb-4 text-center">
|
||||
<div class="glass-panel rounded-lg px-4 py-3 flex flex-col items-center justify-center gap-2">
|
||||
<div class="w-full flex flex-col items-center gap-1">
|
||||
<div class="w-full text-center">
|
||||
<h1 class="text-xl sm:text-2xl font-bold tracking-tight text-white text-glow">
|
||||
IMC 益选便利店
|
||||
</h1>
|
||||
<div class="inline-flex items-center justify-center gap-2 text-[10px] sm:text-xs text-slate-400 font-mono mt-1 leading-none mx-auto">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse"></span>
|
||||
<span>实时监控中</span>
|
||||
<span class="text-slate-500">|</span>
|
||||
<span id="last-updated">LAST UPDATE: -</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="grid grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 mb-4">
|
||||
<!-- Card Template -->
|
||||
<div class="glass-panel p-3 sm:p-4 rounded-lg group hover:bg-slate-800/50 transition-colors" aria-label="今日数据">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-slate-400 text-xs font-bold tracking-wider">今日营业额</span>
|
||||
<div class="flex gap-1">
|
||||
<span id="today-date" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
|
||||
<span id="today-weekday" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="today" class="text-xl sm:text-2xl font-mono-tech font-bold text-cyan-400 text-glow tracking-tight">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel p-3 sm:p-4 rounded-lg group hover:bg-slate-800/50 transition-colors">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-slate-400 text-xs font-bold tracking-wider">昨日营业额</span>
|
||||
<div class="flex gap-1">
|
||||
<span id="yday-date" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
|
||||
<span id="yday-weekday" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="yday" class="text-xl sm:text-2xl font-mono-tech font-semibold text-slate-300 tracking-tight">-</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel p-3 sm:p-4 rounded-lg group hover:bg-slate-800/50 transition-colors">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-slate-400 text-xs font-bold tracking-wider">前日营业额</span>
|
||||
<div class="flex gap-1">
|
||||
<span id="day-before-date" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
|
||||
<span id="day-before-weekday" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="day-before" class="text-xl sm:text-2xl font-mono-tech font-semibold text-slate-400 tracking-tight">-</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel p-3 sm:p-4 rounded-lg group hover:bg-slate-800/50 transition-colors">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-slate-400 text-xs font-bold tracking-wider">本周累计</span>
|
||||
<span id="this-week-range" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
|
||||
</div>
|
||||
<div id="this-week" class="text-xl sm:text-2xl font-mono-tech font-bold text-white tracking-tight">-</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel p-3 sm:p-4 rounded-lg group hover:bg-slate-800/50 transition-colors">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-slate-400 text-xs font-bold tracking-wider">上周累计</span>
|
||||
<span id="last-week-range" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
|
||||
</div>
|
||||
<div id="last-week" class="text-xl sm:text-2xl font-mono-tech font-semibold text-slate-300 tracking-tight">-</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel p-3 sm:p-4 rounded-lg group hover:bg-slate-800/50 transition-colors">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-slate-400 text-xs font-bold tracking-wider">本月累计</span>
|
||||
<span id="this-month-range" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
|
||||
</div>
|
||||
<div id="this-month" class="text-xl sm:text-2xl font-mono-tech font-bold text-white tracking-tight">-</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="glass-panel p-3 sm:p-4 rounded-lg mt-5 relative">
|
||||
<div class="flex flex-col xl:flex-row items-center justify-between mb-4 gap-3 z-10 relative">
|
||||
<div class="flex p-0.5 bg-slate-900/50 rounded border border-slate-700/50 backdrop-blur">
|
||||
<button id="view-7" class="btn-tech px-2.5 py-1 text-xs rounded mr-0.5">最近7天</button>
|
||||
<button id="view-this-month" class="btn-tech px-2.5 py-1 text-xs rounded mr-0.5">本月</button>
|
||||
<button id="view-last-month" class="btn-tech px-2.5 py-1 text-xs rounded mr-0.5">上月</button>
|
||||
<button id="view-90" class="btn-tech px-2.5 py-1 text-xs rounded">最近90天</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="export-img" class="btn-tech px-2.5 py-1 text-xs rounded flex items-center gap-1.5 group">
|
||||
<span class="group-hover:animate-bounce">📷</span> 图片
|
||||
</button>
|
||||
<button id="export-csv" class="btn-tech px-2.5 py-1 text-xs rounded flex items-center gap-1.5 group">
|
||||
<span class="group-hover:animate-bounce">📄</span> 表格
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full relative" style="height:280px">
|
||||
<canvas id="chart" aria-label="趋势分析图表"></canvas>
|
||||
</div>
|
||||
<div id="chart-msg" class="text-xs text-cyan-500/50 mt-2 font-mono text-center tracking-widest uppercase" aria-live="polite"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const api = path => `/api/${path}`;
|
||||
Chart.register(ChartDataLabels);
|
||||
// Dark mode default configuration for Chart.js
|
||||
Chart.defaults.color = '#94a3b8';
|
||||
Chart.defaults.borderColor = 'rgba(34, 211, 238, 0.1)';
|
||||
Chart.defaults.font.family = "'JetBrains Mono', 'Fira Code', 'Consolas', monospace";
|
||||
|
||||
let shopName = '益选便利店';
|
||||
let seriesDates = [];
|
||||
const isMobile = window.matchMedia('(max-width: 639px)').matches;
|
||||
const maxTicks = isMobile ? 7 : 14;
|
||||
let targetY = null;
|
||||
let viewMode = 'recent7';
|
||||
|
||||
const weekendBackground = {
|
||||
id: 'weekendBackground',
|
||||
beforeDraw(chart, args, opts){
|
||||
const dates = opts?.dates || [];
|
||||
const {ctx, chartArea: {left, right, top, bottom}, scales: {x}} = chart;
|
||||
if (!x || dates.length === 0) return;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(34, 211, 238, 0.05)'; // Tech cyan low opacity
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const d = new Date(dates[i] + 'T00:00:00');
|
||||
const wd = d.getDay();
|
||||
if (wd === 0 || wd === 6) {
|
||||
const x1 = x.getPixelForTick ? x.getPixelForTick(i) : x.getPixelForValue(i);
|
||||
const x2 = (i < dates.length - 1)
|
||||
? (x.getPixelForTick ? x.getPixelForTick(i+1) : x.getPixelForValue(i+1))
|
||||
: right;
|
||||
ctx.fillRect(x1, top, (x2 - x1), bottom - top);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
Chart.register(weekendBackground);
|
||||
|
||||
function fmtYuan(n){ return '¥' + Number(n).toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2}); }
|
||||
function fmtRange(a,b){ const sa=a.slice(5), sb=b.slice(5); return `${sa}~${sb}`; }
|
||||
function weekdayStr(ds){
|
||||
const map = ['周日','周一','周二','周三','周四','周五','周六'];
|
||||
const d = new Date(ds + 'T00:00:00');
|
||||
return map[d.getDay()];
|
||||
}
|
||||
|
||||
function parseYuanText(t){
|
||||
if (!t) return 0;
|
||||
const s = String(t).replace(/[^0-9.\-]/g,'');
|
||||
const n = parseFloat(s);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function animateYuan(el, to, ms=600){
|
||||
const fmt = v => '¥' + Number(v).toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
const from = parseYuanText(el.textContent);
|
||||
const start = performance.now();
|
||||
// Ease out cubic
|
||||
function step(ts){
|
||||
const t = Math.min(1, (ts - start) / ms);
|
||||
const e = 1 - Math.pow(1 - t, 3);
|
||||
const v = from + (to - from) * e;
|
||||
el.textContent = fmt(v);
|
||||
if (t < 1) requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
async function loadMetrics(){
|
||||
let m;
|
||||
try {
|
||||
document.getElementById('chart-msg').textContent = 'Loading data...';
|
||||
const res = await fetch(api('metrics'));
|
||||
m = await res.json();
|
||||
} catch (e) {
|
||||
document.getElementById('last-updated').textContent = 'Status: Offline';
|
||||
return;
|
||||
}
|
||||
shopName = m.shop_name || shopName;
|
||||
document.title = `IMC ${shopName} Dashboard`;
|
||||
|
||||
// Update H1 logic removed to preserve custom Tech header structure
|
||||
if (chart?.options?.plugins?.title) {
|
||||
chart.options.plugins.title.text = shopName;
|
||||
chart.update('none');
|
||||
}
|
||||
if (m && typeof m.last_week?.total === 'number') {
|
||||
targetY = m.last_week.total / 7;
|
||||
}
|
||||
document.getElementById('last-updated').textContent = `LAST UPDATE: ${m.server_now.slice(11,16)}`;
|
||||
|
||||
// Update data with animation
|
||||
document.getElementById('today-date').textContent = m.today.date.slice(5);
|
||||
document.getElementById('today-weekday').textContent = m.today.weekday;
|
||||
if (m.today.amount == null) {
|
||||
document.getElementById('today').textContent = '暂无数据';
|
||||
document.getElementById('today').classList.add('text-slate-500');
|
||||
} else {
|
||||
document.getElementById('today').classList.remove('text-slate-500');
|
||||
animateYuan(document.getElementById('today'), m.today.amount);
|
||||
}
|
||||
|
||||
document.getElementById('yday-date').textContent = m.yesterday.date.slice(5);
|
||||
document.getElementById('yday-weekday').textContent = weekdayStr(m.yesterday.date);
|
||||
if (m.yesterday.amount == null) {
|
||||
document.getElementById('yday').textContent = '暂无数据';
|
||||
document.getElementById('yday').classList.add('text-slate-500');
|
||||
} else {
|
||||
document.getElementById('yday').classList.remove('text-slate-500');
|
||||
animateYuan(document.getElementById('yday'), m.yesterday.amount);
|
||||
}
|
||||
|
||||
document.getElementById('day-before-date').textContent = m.day_before.date.slice(5);
|
||||
document.getElementById('day-before-weekday').textContent = weekdayStr(m.day_before.date);
|
||||
if (m.day_before.amount == null) {
|
||||
document.getElementById('day-before').textContent = '暂无数据';
|
||||
document.getElementById('day-before').classList.add('text-slate-500');
|
||||
} else {
|
||||
document.getElementById('day-before').classList.remove('text-slate-500');
|
||||
animateYuan(document.getElementById('day-before'), m.day_before.amount);
|
||||
}
|
||||
|
||||
document.getElementById('this-week-range').textContent = fmtRange(m.this_week.start, m.this_week.end);
|
||||
animateYuan(document.getElementById('this-week'), m.this_week.total);
|
||||
|
||||
document.getElementById('last-week-range').textContent = fmtRange(m.last_week.start, m.last_week.end);
|
||||
animateYuan(document.getElementById('last-week'), m.last_week.total);
|
||||
|
||||
document.getElementById('this-month-range').textContent = fmtRange(m.this_month.start, m.this_month.end);
|
||||
animateYuan(document.getElementById('this-month'), m.this_month.total);
|
||||
}
|
||||
|
||||
document.getElementById('export-csv').addEventListener('click', () => location.href = api('export'));
|
||||
document.getElementById('export-img').addEventListener('click', () => {
|
||||
if (!chart) return;
|
||||
const url = chart.toBase64Image('image/png', 1.0);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const start = seriesDates[0];
|
||||
const end = seriesDates[seriesDates.length - 1];
|
||||
const fallback = viewMode === 'this_month' ? `${shopName}_本月.png` : (viewMode === 'last_month' ? `${shopName}_上月.png` : (viewMode === 'recent90' ? `${shopName}_最近90天.png` : `${shopName}_最近7天.png`));
|
||||
const fname = start && end ? `${shopName}_${start}_${end}.png` : fallback;
|
||||
a.download = fname;
|
||||
a.click();
|
||||
});
|
||||
|
||||
let chart;
|
||||
async function loadSeries(){
|
||||
try {
|
||||
let s;
|
||||
if (viewMode === 'recent7') {
|
||||
const res = await fetch(api('series7') + `?days=7&v=${Date.now()}`);
|
||||
s = await res.json();
|
||||
} else {
|
||||
const res = await fetch(api('series7') + `?days=90&v=${Date.now()}`);
|
||||
const all = await res.json();
|
||||
if (all.length === 0) {
|
||||
s = all;
|
||||
} else {
|
||||
const endDateStr = all[all.length - 1].date;
|
||||
const endDate = new Date(endDateStr + 'T00:00:00');
|
||||
const endYear = endDate.getFullYear();
|
||||
const endMonth = endDate.getMonth();
|
||||
let y = endYear, m = endMonth;
|
||||
if (viewMode === 'recent90') {
|
||||
s = all;
|
||||
} else {
|
||||
if (viewMode === 'last_month') {
|
||||
m = endMonth - 1; if (m < 0) { m = 11; y = endYear - 1; }
|
||||
}
|
||||
const monthStr = `${y}-${String(m + 1).padStart(2, '0')}`;
|
||||
s = all.filter(d => d.date.startsWith(monthStr));
|
||||
}
|
||||
}
|
||||
}
|
||||
seriesDates = s.map(d => d.date);
|
||||
|
||||
if (!s.length) {
|
||||
if (chart) { chart.destroy(); chart = null; }
|
||||
document.getElementById('chart-msg').textContent = 'NO DATA AVAILABLE';
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('chart').getContext('2d');
|
||||
if (chart) chart.destroy();
|
||||
const xPadding = isMobile ? 10 : 20;
|
||||
|
||||
// Create gradient
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
|
||||
gradient.addColorStop(0, 'rgba(34, 211, 238, 0.5)'); // Cyan
|
||||
gradient.addColorStop(1, 'rgba(34, 211, 238, 0.0)');
|
||||
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: s.map(d => [d.date.slice(5), weekdayStr(d.date)]),
|
||||
datasets: (() => {
|
||||
const values = s.map(d => d.amount);
|
||||
const avg = values.length ? (values.reduce((a,b)=>a+b,0) / values.length) : 0;
|
||||
const maxVal = Math.max(...values);
|
||||
const minVal = Math.min(...values);
|
||||
const maxIdx = values.indexOf(maxVal);
|
||||
const minIdx = values.indexOf(minVal);
|
||||
|
||||
const sets = [
|
||||
{
|
||||
label: '营业额',
|
||||
data: values,
|
||||
borderColor: '#22d3ee', // Cyan-400
|
||||
borderWidth: 3,
|
||||
backgroundColor: gradient,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
spanGaps: true,
|
||||
pointBackgroundColor: '#0f172a',
|
||||
pointBorderColor: '#22d3ee',
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: isMobile ? 3 : 5,
|
||||
pointHoverRadius: isMobile ? 5 : 7,
|
||||
pointHoverBackgroundColor: '#22d3ee',
|
||||
pointHoverBorderColor: '#fff'
|
||||
},
|
||||
{
|
||||
label: '平均线',
|
||||
data: values.map(() => avg),
|
||||
borderColor: 'rgba(148, 163, 184, 0.5)', // Slate-400
|
||||
borderDash: [5,5],
|
||||
borderWidth: 1,
|
||||
tension: 0,
|
||||
fill: false,
|
||||
pointRadius: 0
|
||||
}
|
||||
];
|
||||
if (typeof targetY === 'number') {
|
||||
sets.push({
|
||||
label: '目标线',
|
||||
data: values.map(() => targetY),
|
||||
borderColor: '#fbbf24', // Amber-400
|
||||
borderDash: [3,3],
|
||||
borderWidth: 1,
|
||||
tension: 0,
|
||||
fill: false,
|
||||
pointRadius: 0
|
||||
});
|
||||
}
|
||||
return sets;
|
||||
})()
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { intersect: false, mode: 'index' },
|
||||
animation: { duration: 800, easing: 'easeOutQuart' },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
datalabels: {
|
||||
display: !isMobile,
|
||||
align: 'top',
|
||||
anchor: 'end',
|
||||
offset: 8,
|
||||
formatter: (v, ctx) => {
|
||||
const values = ctx.chart.data.datasets[0].data;
|
||||
const maxVal = Math.max(...values);
|
||||
const minVal = Math.min(...values);
|
||||
const isMax = v === maxVal;
|
||||
const isMin = v === minVal;
|
||||
|
||||
// Only show max, min and every few points to avoid clutter
|
||||
if (!isMax && !isMin) {
|
||||
// Simple logic to show some labels but not all if crowded
|
||||
if (values.length > 10 && ctx.dataIndex % 3 !== 0) return '';
|
||||
}
|
||||
|
||||
const str = '¥' + Number(v).toLocaleString('zh-CN', {minimumFractionDigits: 0, maximumFractionDigits: 0});
|
||||
return isMax ? '🔥 ' + str : (isMin ? '❄️ ' + str : str);
|
||||
},
|
||||
color: (ctx) => {
|
||||
const v = ctx.dataset.data[ctx.dataIndex];
|
||||
const values = ctx.chart.data.datasets[0].data;
|
||||
if (v === Math.max(...values)) return '#fbbf24'; // Amber
|
||||
if (v === Math.min(...values)) return '#94a3b8'; // Slate
|
||||
return '#e2e8f0'; // Slate-200
|
||||
},
|
||||
font: { weight: 'bold', family: 'monospace' }
|
||||
},
|
||||
weekendBackground: { dates: s.map(d => d.date) },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.9)',
|
||||
titleColor: '#e2e8f0',
|
||||
bodyColor: '#22d3ee',
|
||||
borderColor: 'rgba(34, 211, 238, 0.3)',
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
title: (items) => items[0].label,
|
||||
label: (ctx) => {
|
||||
const v = Number(ctx.parsed.y);
|
||||
const vals = ctx.chart.data.datasets[0].data;
|
||||
const i = ctx.dataIndex;
|
||||
let base = '¥' + v.toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
if (i > 0 && Number.isFinite(vals[i-1])) {
|
||||
const diff = v - vals[i-1];
|
||||
const sign = diff >= 0 ? '▲' : '▼';
|
||||
const diffStr = Number(Math.abs(diff)).toLocaleString('zh-CN', {minimumFractionDigits: 2});
|
||||
return `${base} (${sign} ${diffStr})`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
},
|
||||
zoom: {
|
||||
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' },
|
||||
pan: { enabled: true, mode: 'x' }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: 'rgba(148, 163, 184, 0.1)' },
|
||||
ticks: { color: '#94a3b8', maxRotation: 0, padding: 10, font: {family:'monospace'} }
|
||||
},
|
||||
y: {
|
||||
grid: { color: 'rgba(148, 163, 184, 0.1)' },
|
||||
min: (() => {
|
||||
const vals = s.map(d=>d.amount).filter(v => Number.isFinite(v));
|
||||
if (!vals.length) return 0;
|
||||
const m = Math.min(...vals);
|
||||
return Math.max(0, m * 0.95);
|
||||
})(),
|
||||
ticks: {
|
||||
color: '#64748b',
|
||||
callback: v => '¥' + Number(v).toLocaleString('zh-CN', {compact: 'short'})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
document.getElementById('chart-msg').textContent = '';
|
||||
} catch (e) {
|
||||
console.error('Chart Load Failed', e);
|
||||
document.getElementById('chart-msg').textContent = 'Chart visualization error';
|
||||
}
|
||||
}
|
||||
|
||||
// SSE logic remains same
|
||||
let es;
|
||||
function startSSE(){
|
||||
try {
|
||||
es = new EventSource('/api/events');
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'tick' && msg.server_now) {
|
||||
const tm = msg.server_now.slice(11,16);
|
||||
document.getElementById('last-updated').textContent = `LAST UPDATE: ${tm}`;
|
||||
} else if (msg.type === 'force_refresh') {
|
||||
loadMetrics();
|
||||
loadSeries();
|
||||
}
|
||||
} catch(_){}
|
||||
};
|
||||
es.onerror = () => {
|
||||
if (es) es.close();
|
||||
setInterval(() => { loadMetrics(); }, 60000);
|
||||
setInterval(() => { loadSeries(); }, 120000);
|
||||
};
|
||||
} catch (e) {
|
||||
setInterval(() => { loadMetrics(); }, 60000);
|
||||
setInterval(() => { loadSeries(); }, 120000);
|
||||
}
|
||||
}
|
||||
|
||||
loadMetrics();
|
||||
loadSeries();
|
||||
startSSE();
|
||||
|
||||
const b7 = document.getElementById('view-7');
|
||||
const bm = document.getElementById('view-this-month');
|
||||
const blm = document.getElementById('view-last-month');
|
||||
const b90 = document.getElementById('view-90');
|
||||
|
||||
function updateTabs() {
|
||||
const allBtns = [b7, bm, blm, b90];
|
||||
allBtns.forEach(b => {
|
||||
if(!b) return;
|
||||
b.classList.remove('active');
|
||||
b.setAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
let target;
|
||||
if(viewMode === 'recent7') target = b7;
|
||||
else if(viewMode === 'this_month') target = bm;
|
||||
else if(viewMode === 'last_month') target = blm;
|
||||
else if(viewMode === 'recent90') target = b90;
|
||||
|
||||
if(target) {
|
||||
target.classList.add('active');
|
||||
target.setAttribute('aria-pressed', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
if(b7) b7.addEventListener('click', () => { viewMode='recent7'; updateTabs(); loadSeries(); });
|
||||
if(bm) bm.addEventListener('click', () => { viewMode='this_month'; updateTabs(); loadSeries(); });
|
||||
if(blm) blm.addEventListener('click', () => { viewMode='last_month'; updateTabs(); loadSeries(); });
|
||||
if(b90) b90.addEventListener('click', () => { viewMode='recent90'; updateTabs(); loadSeries(); });
|
||||
|
||||
updateTabs();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
instance/data.db
Normal file
BIN
instance/data.db
Normal file
Binary file not shown.
66
数据修正使用说明.md
Normal file
66
数据修正使用说明.md
Normal file
@ -0,0 +1,66 @@
|
||||
# 数据修正使用说明
|
||||
|
||||
## 目的
|
||||
- 指导如何在不暴露修正过程的前提下,修正某天营业额数据,并确保前端即时一致更新。
|
||||
- 所有修正与生成均写入审计表和 `app.log`,便于稽核与追踪。
|
||||
|
||||
## 前置准备
|
||||
- 在项目根目录创建或编辑 `.env`,设置管理口令:
|
||||
- `ADMIN_TOKEN=你的口令`
|
||||
- 后端运行于 `http://localhost:5000`(Docker 或本地均可)。
|
||||
|
||||
## 修正接口
|
||||
- URL:`PUT /api/admin/turnover`
|
||||
- 鉴权:请求头 `X-Admin-Token: <你的口令>`(来自 `.env`)
|
||||
- Content-Type:`application/json`
|
||||
- 请求体:
|
||||
- `date`:`YYYY-MM-DD`
|
||||
- `amount`:数值(营业额)
|
||||
- `reason`:字符串(修正原因)
|
||||
- `actor`:可选,执行者标识
|
||||
|
||||
## 请求示例
|
||||
- curl(Windows PowerShell 请使用完整双引号并转义):
|
||||
```
|
||||
curl -X PUT http://localhost:5000/api/admin/turnover ^
|
||||
-H "Content-Type: application/json" ^
|
||||
-H "X-Admin-Token: <你的口令>" ^
|
||||
-d "{\"date\":\"2025-12-06\",\"amount\":3123.45,\"reason\":\"调整入账\"}"
|
||||
```
|
||||
- PowerShell(推荐):
|
||||
```
|
||||
$body = @{ date = "2025-12-06"; amount = 3123.45; reason = "调整入账" } | ConvertTo-Json
|
||||
Invoke-RestMethod -Method Put -Uri "http://localhost:5000/api/admin/turnover" -Headers @{"X-Admin-Token"="<你的口令>"} -ContentType "application/json" -Body $body
|
||||
```
|
||||
- Postman:
|
||||
- Method: PUT;URL: `http://localhost:5000/api/admin/turnover`
|
||||
- Headers: `X-Admin-Token: <你的口令>`;`Content-Type: application/json`
|
||||
- Body(JSON):`{"date":"2025-12-06","amount":3123.45,"reason":"调整入账"}`
|
||||
|
||||
## 成功结果与前端效果
|
||||
- 接口返回:`{"ok": true}`
|
||||
- 后端行为:
|
||||
- 写入/更新 `daily_revenue` 对应日期的记录,并置 `is_final=true`
|
||||
- 写入 `audit_log` 审计记录
|
||||
- 追加一行到 `app.log`(与历史格式一致)
|
||||
- 前端:每 60 秒轮询 `GET /api/metrics` 与 `GET /api/series7`,修正后下一次轮询即可显示新值;修正过程不在前端展示。
|
||||
|
||||
## 生成与定版规则(一致性)
|
||||
- 每天本地时间 `23:00` 自动生成并定版当日数据(`is_final=true`)。
|
||||
- 已定版的数据不会被定时任务覆盖;如需变更,请使用修正接口。
|
||||
- `CSV` 导出仅包含已定版数据。
|
||||
|
||||
## 审计与回滚
|
||||
- 审计表:`audit_log`
|
||||
- 字段:`date`、`old_amount`、`new_amount`、`reason`、`actor`、`type(generate/correct/import_log)`、`created_at`
|
||||
- 回滚方式:再次调用修正接口,将 `amount` 改回目标值,并注明 `reason`(例如:`回滚`)。
|
||||
|
||||
## 常见问题
|
||||
- 401 未授权:检查 `.env` 中 `ADMIN_TOKEN` 与请求头 `X-Admin-Token` 是否一致。
|
||||
- 前端未更新:等待轮询或手动刷新;确保后端正在运行并能访问 `GET /api/metrics` 与 `GET /api/series7`。
|
||||
- 时间与周次显示不符:系统按本地时区与“周一为周起、周日为周止”的规则计算区间。
|
||||
|
||||
## 相关接口(只读)
|
||||
- 指标:`GET /api/metrics`(含 `today`:日期与周几;未到 23:00 或未定版则 `amount=null`)
|
||||
- 7 日序列:`GET /api/series7`(今日定版后纳入,否则截止到昨日)
|
||||
- 导出:`GET /api/export`(仅包含已定版数据)
|
||||
Loading…
Reference in New Issue
Block a user