feat: 财务对账页面 — GET /api/finance/daily-summary + Finance.vue
This commit is contained in:
+2
-1
@@ -15,7 +15,7 @@ from app.models.admin_user import AdminUser
|
|||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.utils.auth import hash_password, verify_password, validate_password_supported
|
from app.utils.auth import hash_password, verify_password, validate_password_supported
|
||||||
from app.services.scheduler import start_scheduler, stop_scheduler
|
from app.services.scheduler import start_scheduler, stop_scheduler
|
||||||
from app.routers import auth, upstreams, webhooks, logs, custom_pages, websites, auth_capture, external_api_logs
|
from app.routers import auth, upstreams, webhooks, logs, custom_pages, websites, auth_capture, external_api_logs, finance
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -98,6 +98,7 @@ app.include_router(custom_pages.router)
|
|||||||
app.include_router(websites.router)
|
app.include_router(websites.router)
|
||||||
app.include_router(auth_capture.router)
|
app.include_router(auth_capture.router)
|
||||||
app.include_router(external_api_logs.router)
|
app.include_router(external_api_logs.router)
|
||||||
|
app.include_router(finance.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Finance router — daily revenue vs cost reconciliation."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as _dt
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.upstream import Upstream
|
||||||
|
from app.models.website import Website
|
||||||
|
from app.services.finance_service import get_daily_summary, yesterday_shanghai
|
||||||
|
from app.utils.auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/finance", tags=["finance"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/daily-summary")
|
||||||
|
def daily_summary(
|
||||||
|
date: Optional[str] = Query(None, description="YYYY-MM-DD, defaults to yesterday Asia/Shanghai"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: Any = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Return daily revenue vs cost summary.
|
||||||
|
|
||||||
|
Never raises for individual item failures — each failed website/upstream
|
||||||
|
is included in the response with status='failed' and an error message.
|
||||||
|
Only raises HTTP errors for bad request parameters.
|
||||||
|
"""
|
||||||
|
if date:
|
||||||
|
try:
|
||||||
|
target = _dt.date.fromisoformat(date)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, f"Invalid date format: {date!r}. Expected YYYY-MM-DD.")
|
||||||
|
else:
|
||||||
|
target = yesterday_shanghai()
|
||||||
|
|
||||||
|
websites = db.query(Website).filter(Website.enabled == True).all()
|
||||||
|
upstreams = db.query(Upstream).filter(Upstream.enabled == True).all()
|
||||||
|
|
||||||
|
return get_daily_summary(websites, upstreams, target)
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
"""Finance service — daily revenue vs cost reconciliation.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- Website revenue: HTTP GET /usage/stats via website's existing auth config
|
||||||
|
- Upstream cost (Sub2API): HTTP GET /usage/stats via upstream's bearer token
|
||||||
|
- Upstream cost (New-API/Nox-API): HTTP GET /api/log/stat via UpstreamClient
|
||||||
|
(handles login_password / nox_token / new_api_token auth)
|
||||||
|
|
||||||
|
No DB writes. All data fetched live. Each item fails independently.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from app.models.upstream import Upstream
|
||||||
|
from app.models.website import Website
|
||||||
|
from app.services.upstream_client import UpstreamClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SHANGHAI = pytz.timezone("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Upstream type classification
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def classify_upstream(upstream: Upstream) -> str:
|
||||||
|
"""Return 'sub2api' | 'new_api' | 'unknown'."""
|
||||||
|
prefix = (upstream.api_prefix or "").strip("/")
|
||||||
|
if prefix == "api/v1":
|
||||||
|
return "sub2api"
|
||||||
|
auth = upstream.auth_type or ""
|
||||||
|
if auth in ("new_api_token", "nox_token"):
|
||||||
|
return "new_api"
|
||||||
|
if auth == "login_password":
|
||||||
|
try:
|
||||||
|
cfg = json.loads(upstream.auth_config_json or "{}")
|
||||||
|
except Exception:
|
||||||
|
cfg = {}
|
||||||
|
if "/api/user/login" in (cfg.get("login_path") or ""):
|
||||||
|
return "new_api"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Date helpers
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def yesterday_shanghai() -> date:
|
||||||
|
return datetime.now(_SHANGHAI).date() - timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def date_to_shanghai_timestamps(d: date) -> tuple[int, int]:
|
||||||
|
"""Return (start_unix, end_unix) for 00:00:00..23:59:59 Asia/Shanghai on date d."""
|
||||||
|
start = _SHANGHAI.localize(datetime(d.year, d.month, d.day, 0, 0, 0))
|
||||||
|
end = _SHANGHAI.localize(datetime(d.year, d.month, d.day, 23, 59, 59))
|
||||||
|
return int(start.timestamp()), int(end.timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def _build_website_headers(auth_type: str, auth_config: dict) -> dict[str, str]:
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "SmartUp/1.0"}
|
||||||
|
if auth_type == "api_key":
|
||||||
|
key = auth_config.get("key") or auth_config.get("api_key") or ""
|
||||||
|
header_name = auth_config.get("header") or "x-api-key"
|
||||||
|
if key:
|
||||||
|
headers[header_name] = key
|
||||||
|
elif auth_type == "bearer":
|
||||||
|
token = auth_config.get("token") or ""
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Website revenue
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def fetch_website_revenue(website: Website, target_date: date) -> tuple[float, str | None]:
|
||||||
|
"""Fetch website revenue for target_date.
|
||||||
|
|
||||||
|
Calls GET /usage/stats on the Sub2API admin endpoint with the website's
|
||||||
|
existing auth config (same as all other website operations in SmartUp).
|
||||||
|
Returns (amount, error_message). error_message is None on success.
|
||||||
|
"""
|
||||||
|
date_str = target_date.isoformat()
|
||||||
|
try:
|
||||||
|
auth_config = json.loads(website.auth_config_json or "{}")
|
||||||
|
prefix = (website.api_prefix or "").strip("/")
|
||||||
|
prefix_part = f"/{prefix}" if prefix else ""
|
||||||
|
url = f"{website.base_url.rstrip('/')}{prefix_part}/usage/stats"
|
||||||
|
params = {
|
||||||
|
"start_date": date_str,
|
||||||
|
"end_date": date_str,
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"nocache": "true",
|
||||||
|
}
|
||||||
|
headers = _build_website_headers(website.auth_type, auth_config)
|
||||||
|
resp = httpx.get(url, params=params, headers=headers, timeout=float(website.timeout_seconds))
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
# Sub2API wraps in data: { total_actual_cost, ... }
|
||||||
|
payload = data.get("data", data) if isinstance(data, dict) else {}
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
cost = payload.get("total_actual_cost")
|
||||||
|
if cost is not None:
|
||||||
|
return float(cost), None
|
||||||
|
return 0.0, "响应中找不到 total_actual_cost 字段"
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return 0.0, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
|
||||||
|
except Exception as e:
|
||||||
|
return 0.0, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Upstream cost — Sub2API
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def fetch_upstream_cost_sub2api(upstream: Upstream, target_date: date) -> tuple[float, str | None]:
|
||||||
|
"""Fetch cost for Sub2API-type upstream via /usage/stats (user-facing endpoint).
|
||||||
|
|
||||||
|
Sub2API's /usage/stats auto-adds 1 day to end_date internally, so passing
|
||||||
|
the same date for start and end gives the full day's data.
|
||||||
|
"""
|
||||||
|
date_str = target_date.isoformat()
|
||||||
|
try:
|
||||||
|
auth_config = json.loads(upstream.auth_config_json or "{}")
|
||||||
|
prefix = (upstream.api_prefix or "").strip("/")
|
||||||
|
prefix_part = f"/{prefix}" if prefix else ""
|
||||||
|
url = f"{upstream.base_url.rstrip('/')}{prefix_part}/usage/stats"
|
||||||
|
params = {
|
||||||
|
"start_date": date_str,
|
||||||
|
"end_date": date_str,
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
}
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "SmartUp/1.0"}
|
||||||
|
token = auth_config.get("token") or ""
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
resp = httpx.get(url, params=params, headers=headers, timeout=float(upstream.timeout_seconds))
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
payload = data.get("data", data) if isinstance(data, dict) else {}
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
cost = payload.get("total_actual_cost")
|
||||||
|
if cost is not None:
|
||||||
|
return float(cost), None
|
||||||
|
return 0.0, "响应中找不到 total_actual_cost 字段"
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return 0.0, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
|
||||||
|
except Exception as e:
|
||||||
|
return 0.0, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Upstream cost — New-API / Nox-API
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def fetch_upstream_cost_new_api(upstream: Upstream, target_date: date) -> tuple[float, str | None]:
|
||||||
|
"""Fetch cost for New-API/Nox-API upstream via /api/log/stat.
|
||||||
|
|
||||||
|
Uses UpstreamClient for auth (handles login_password refresh, nox_token, etc.).
|
||||||
|
Timestamp range: 00:00:00 to 23:59:59 Asia/Shanghai (closed interval, as per
|
||||||
|
New-API source: created_at <= endTimestamp).
|
||||||
|
"""
|
||||||
|
start_ts, end_ts = date_to_shanghai_timestamps(target_date)
|
||||||
|
auth_config = json.loads(upstream.auth_config_json or "{}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with UpstreamClient(
|
||||||
|
base_url=upstream.base_url,
|
||||||
|
api_prefix=upstream.api_prefix,
|
||||||
|
auth_type=upstream.auth_type,
|
||||||
|
auth_config=auth_config,
|
||||||
|
timeout=float(upstream.timeout_seconds),
|
||||||
|
on_auth_config_update=lambda _: None, # read-only, no DB writes
|
||||||
|
target_id=upstream.id,
|
||||||
|
target_name=upstream.name,
|
||||||
|
) as client:
|
||||||
|
client.ensure_authenticated()
|
||||||
|
# Get quota_per_unit from /api/status (cached within this context)
|
||||||
|
quota_per_unit = client._new_api_quota_per_unit()
|
||||||
|
|
||||||
|
# Build auth headers after authentication
|
||||||
|
token = client._token or auth_config.get("token") or ""
|
||||||
|
user_id = (
|
||||||
|
client._new_api_user
|
||||||
|
or auth_config.get("new_api_user")
|
||||||
|
or auth_config.get("user_id")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make the stats call with params (UpstreamClient._request doesn't support params)
|
||||||
|
prefix = (upstream.api_prefix or "").strip("/")
|
||||||
|
prefix_part = f"/{prefix}" if prefix else ""
|
||||||
|
url = f"{upstream.base_url.rstrip('/')}{prefix_part}/api/log/stat"
|
||||||
|
params = {"type": 2, "start_timestamp": start_ts, "end_timestamp": end_ts}
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "SmartUp/1.0"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
if user_id:
|
||||||
|
headers["New-Api-User"] = user_id
|
||||||
|
headers["Nox-Api-User"] = user_id
|
||||||
|
|
||||||
|
resp = httpx.get(url, params=params, headers=headers, timeout=float(upstream.timeout_seconds))
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
payload = data.get("data", data) if isinstance(data, dict) else {}
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
quota = payload.get("quota")
|
||||||
|
if quota is not None:
|
||||||
|
cost = float(quota) / max(quota_per_unit, 1)
|
||||||
|
return round(cost, 6), None
|
||||||
|
return 0.0, "响应中找不到 quota 字段"
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return 0.0, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
|
||||||
|
except Exception as e:
|
||||||
|
return 0.0, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Orchestration
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_daily_summary(
|
||||||
|
websites: list[Website],
|
||||||
|
upstreams: list[Upstream],
|
||||||
|
target_date: date,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Compute daily revenue vs cost summary.
|
||||||
|
|
||||||
|
Never raises — each website/upstream failure is recorded in the item's
|
||||||
|
status/error fields. Failures do NOT contribute to totals (safe default:
|
||||||
|
under-count rather than over-count profit).
|
||||||
|
"""
|
||||||
|
website_items: list[dict[str, Any]] = []
|
||||||
|
upstream_items: list[dict[str, Any]] = []
|
||||||
|
total_revenue = 0.0
|
||||||
|
total_cost = 0.0
|
||||||
|
|
||||||
|
for w in websites:
|
||||||
|
amount, err = fetch_website_revenue(w, target_date)
|
||||||
|
item: dict[str, Any] = {
|
||||||
|
"id": w.id,
|
||||||
|
"name": w.name,
|
||||||
|
"upstream_type": "sub2api",
|
||||||
|
"amount": round(amount, 6),
|
||||||
|
"status": "failed" if err else "success",
|
||||||
|
"error": err,
|
||||||
|
}
|
||||||
|
website_items.append(item)
|
||||||
|
if not err:
|
||||||
|
total_revenue += amount
|
||||||
|
|
||||||
|
for u in upstreams:
|
||||||
|
utype = classify_upstream(u)
|
||||||
|
if utype == "sub2api":
|
||||||
|
amount, err = fetch_upstream_cost_sub2api(u, target_date)
|
||||||
|
elif utype == "new_api":
|
||||||
|
amount, err = fetch_upstream_cost_new_api(u, target_date)
|
||||||
|
else:
|
||||||
|
amount, err = 0.0, (
|
||||||
|
f"未知上游类型(auth_type={u.auth_type}, api_prefix={u.api_prefix}),"
|
||||||
|
"无法统计消费。请检查上游配置。"
|
||||||
|
)
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"id": u.id,
|
||||||
|
"name": u.name,
|
||||||
|
"upstream_type": utype,
|
||||||
|
"amount": round(amount, 6),
|
||||||
|
"status": "failed" if err else "success",
|
||||||
|
"error": err,
|
||||||
|
}
|
||||||
|
upstream_items.append(item)
|
||||||
|
if not err:
|
||||||
|
total_cost += amount
|
||||||
|
|
||||||
|
net = total_revenue - total_cost
|
||||||
|
margin = (net / total_revenue * 100) if total_revenue > 0 else 0.0
|
||||||
|
failed_count = sum(1 for i in website_items + upstream_items if i["status"] == "failed")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": target_date.isoformat(),
|
||||||
|
"total_revenue": round(total_revenue, 6),
|
||||||
|
"total_cost": round(total_cost, 6),
|
||||||
|
"net_income": round(net, 6),
|
||||||
|
"margin_percent": round(margin, 2),
|
||||||
|
"website_items": website_items,
|
||||||
|
"upstream_items": upstream_items,
|
||||||
|
"failed_count": failed_count,
|
||||||
|
"success": failed_count == 0,
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""Tests for finance daily summary service."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.finance_service import (
|
||||||
|
classify_upstream,
|
||||||
|
date_to_shanghai_timestamps,
|
||||||
|
fetch_upstream_cost_new_api,
|
||||||
|
fetch_website_revenue,
|
||||||
|
get_daily_summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Fake model helpers
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
class FakeUpstream:
|
||||||
|
def __init__(self, **kw):
|
||||||
|
self.id = kw.get("id", 1)
|
||||||
|
self.name = kw.get("name", "U1")
|
||||||
|
self.base_url = kw.get("base_url", "http://up.test")
|
||||||
|
self.api_prefix = kw.get("api_prefix", "api/v1")
|
||||||
|
self.auth_type = kw.get("auth_type", "bearer")
|
||||||
|
self.auth_config_json = kw.get("auth_config_json", json.dumps({"token": "tok"}))
|
||||||
|
self.timeout_seconds = kw.get("timeout_seconds", 30)
|
||||||
|
self.enabled = True
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWebsite:
|
||||||
|
def __init__(self, **kw):
|
||||||
|
self.id = kw.get("id", 1)
|
||||||
|
self.name = kw.get("name", "W1")
|
||||||
|
self.base_url = kw.get("base_url", "http://web.test")
|
||||||
|
self.api_prefix = kw.get("api_prefix", "api/v1/admin")
|
||||||
|
self.auth_type = kw.get("auth_type", "api_key")
|
||||||
|
self.auth_config_json = kw.get("auth_config_json", json.dumps({"key": "k1", "header": "x-api-key"}))
|
||||||
|
self.timeout_seconds = kw.get("timeout_seconds", 30)
|
||||||
|
self.enabled = True
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# classify_upstream tests
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_classify_upstream_sub2api():
|
||||||
|
u = FakeUpstream(api_prefix="api/v1", auth_type="bearer")
|
||||||
|
assert classify_upstream(u) == "sub2api"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_upstream_new_api_by_token_type():
|
||||||
|
u = FakeUpstream(api_prefix="", auth_type="new_api_token")
|
||||||
|
assert classify_upstream(u) == "new_api"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_upstream_nox_token_type():
|
||||||
|
u = FakeUpstream(api_prefix="", auth_type="nox_token")
|
||||||
|
assert classify_upstream(u) == "new_api"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_upstream_new_api_by_login_path():
|
||||||
|
u = FakeUpstream(
|
||||||
|
api_prefix="",
|
||||||
|
auth_type="login_password",
|
||||||
|
auth_config_json=json.dumps({"email": "a@b.com", "password": "p", "login_path": "/api/user/login"}),
|
||||||
|
)
|
||||||
|
assert classify_upstream(u) == "new_api"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_upstream_unknown():
|
||||||
|
u = FakeUpstream(api_prefix="", auth_type="bearer")
|
||||||
|
assert classify_upstream(u) == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_upstream_login_password_without_new_api_path():
|
||||||
|
u = FakeUpstream(
|
||||||
|
api_prefix="",
|
||||||
|
auth_type="login_password",
|
||||||
|
auth_config_json=json.dumps({"email": "a@b.com", "password": "p"}),
|
||||||
|
)
|
||||||
|
assert classify_upstream(u) == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Date/timestamp tests
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_date_to_shanghai_timestamps():
|
||||||
|
start_ts, end_ts = date_to_shanghai_timestamps(date(2026, 7, 2))
|
||||||
|
# 2026-07-02 00:00:00 CST = 2026-07-01 16:00:00 UTC = 1782921600
|
||||||
|
# 2026-07-02 23:59:59 CST = 2026-07-02 15:59:59 UTC = 1783007999
|
||||||
|
assert start_ts == 1782921600
|
||||||
|
assert end_ts == 1783007999
|
||||||
|
assert end_ts - start_ts == 86399 # exactly one day minus one second
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# fetch_website_revenue tests
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_mock_response(json_body: dict, status_code: int = 200):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status_code = status_code
|
||||||
|
mock_resp.json.return_value = json_body
|
||||||
|
mock_resp.text = json.dumps(json_body)
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
if status_code >= 400:
|
||||||
|
mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||||
|
"error", request=MagicMock(), response=mock_resp
|
||||||
|
)
|
||||||
|
return mock_resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_website_revenue_success(monkeypatch):
|
||||||
|
w = FakeWebsite()
|
||||||
|
mock_resp = _make_mock_response({"data": {"total_actual_cost": 123.45}})
|
||||||
|
monkeypatch.setattr("httpx.get", lambda *a, **kw: mock_resp)
|
||||||
|
|
||||||
|
amount, err = fetch_website_revenue(w, date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert err is None
|
||||||
|
assert abs(amount - 123.45) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_website_revenue_nested_data(monkeypatch):
|
||||||
|
"""total_actual_cost directly in response root (no wrapping data key)."""
|
||||||
|
w = FakeWebsite()
|
||||||
|
mock_resp = _make_mock_response({"total_actual_cost": 55.5})
|
||||||
|
monkeypatch.setattr("httpx.get", lambda *a, **kw: mock_resp)
|
||||||
|
|
||||||
|
amount, err = fetch_website_revenue(w, date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert err is None
|
||||||
|
assert abs(amount - 55.5) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_website_revenue_no_field(monkeypatch):
|
||||||
|
w = FakeWebsite()
|
||||||
|
mock_resp = _make_mock_response({"data": {"some_other_field": 99}})
|
||||||
|
monkeypatch.setattr("httpx.get", lambda *a, **kw: mock_resp)
|
||||||
|
|
||||||
|
amount, err = fetch_website_revenue(w, date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert amount == 0.0
|
||||||
|
assert err is not None
|
||||||
|
assert "total_actual_cost" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_website_revenue_http_error(monkeypatch):
|
||||||
|
w = FakeWebsite()
|
||||||
|
bad_resp = MagicMock()
|
||||||
|
bad_resp.status_code = 403
|
||||||
|
bad_resp.text = "Forbidden"
|
||||||
|
exc = httpx.HTTPStatusError("403 Forbidden", request=MagicMock(), response=bad_resp)
|
||||||
|
monkeypatch.setattr("httpx.get", lambda *a, **kw: (_ for _ in ()).throw(exc))
|
||||||
|
|
||||||
|
amount, err = fetch_website_revenue(w, date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert amount == 0.0
|
||||||
|
assert err is not None
|
||||||
|
assert "403" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_website_revenue_connection_error(monkeypatch):
|
||||||
|
w = FakeWebsite()
|
||||||
|
monkeypatch.setattr("httpx.get", lambda *a, **kw: (_ for _ in ()).throw(Exception("connection refused")))
|
||||||
|
|
||||||
|
amount, err = fetch_website_revenue(w, date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert amount == 0.0
|
||||||
|
assert err is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# fetch_upstream_cost_new_api tests
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
class FakeUpstreamClientCtx:
|
||||||
|
"""Context manager mock for UpstreamClient."""
|
||||||
|
def __init__(self, token="tok", user_id="u1", quota_per_unit=500000):
|
||||||
|
self._token = token
|
||||||
|
self._new_api_user = user_id
|
||||||
|
self._quota_per_unit = quota_per_unit
|
||||||
|
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *a): pass
|
||||||
|
def ensure_authenticated(self): pass
|
||||||
|
def _new_api_quota_per_unit(self): return self._quota_per_unit
|
||||||
|
|
||||||
|
|
||||||
|
def test_upstream_cost_new_api_success(monkeypatch):
|
||||||
|
u = FakeUpstream(api_prefix="", auth_type="new_api_token")
|
||||||
|
mock_client_instance = FakeUpstreamClientCtx(quota_per_unit=500000)
|
||||||
|
monkeypatch.setattr("app.services.finance_service.UpstreamClient", lambda **kw: mock_client_instance)
|
||||||
|
|
||||||
|
mock_resp = _make_mock_response({"data": {"quota": 5_000_000}})
|
||||||
|
monkeypatch.setattr("httpx.get", lambda *a, **kw: mock_resp)
|
||||||
|
|
||||||
|
amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert err is None
|
||||||
|
assert abs(amount - 10.0) < 1e-6 # 5_000_000 / 500_000 = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_upstream_cost_new_api_missing_quota(monkeypatch):
|
||||||
|
u = FakeUpstream(api_prefix="", auth_type="new_api_token")
|
||||||
|
monkeypatch.setattr("app.services.finance_service.UpstreamClient", lambda **kw: FakeUpstreamClientCtx())
|
||||||
|
|
||||||
|
mock_resp = _make_mock_response({"data": {}})
|
||||||
|
monkeypatch.setattr("httpx.get", lambda *a, **kw: mock_resp)
|
||||||
|
|
||||||
|
amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert amount == 0.0
|
||||||
|
assert err is not None
|
||||||
|
assert "quota" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_upstream_cost_new_api_http_error(monkeypatch):
|
||||||
|
u = FakeUpstream(api_prefix="", auth_type="new_api_token")
|
||||||
|
monkeypatch.setattr("app.services.finance_service.UpstreamClient", lambda **kw: FakeUpstreamClientCtx())
|
||||||
|
|
||||||
|
bad_resp = MagicMock()
|
||||||
|
bad_resp.status_code = 401
|
||||||
|
bad_resp.text = "Unauthorized"
|
||||||
|
exc = httpx.HTTPStatusError("401", request=MagicMock(), response=bad_resp)
|
||||||
|
monkeypatch.setattr("httpx.get", lambda *a, **kw: (_ for _ in ()).throw(exc))
|
||||||
|
|
||||||
|
amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert amount == 0.0
|
||||||
|
assert err is not None
|
||||||
|
assert "401" in err
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# get_daily_summary integration-style tests
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_get_daily_summary_website_failure_excluded_from_total(monkeypatch):
|
||||||
|
"""Failed websites must NOT be counted in total_revenue."""
|
||||||
|
w_ok = FakeWebsite(id=1, name="OK")
|
||||||
|
w_fail = FakeWebsite(id=2, name="Fail")
|
||||||
|
|
||||||
|
call_count = {"n": 0}
|
||||||
|
def mock_get(*a, **kw):
|
||||||
|
call_count["n"] += 1
|
||||||
|
url = a[0] if a else kw.get("url", "")
|
||||||
|
# First call (OK website) returns valid data
|
||||||
|
if call_count["n"] == 1:
|
||||||
|
return _make_mock_response({"data": {"total_actual_cost": 50.0}})
|
||||||
|
# Second call (Fail website) raises
|
||||||
|
raise Exception("timeout")
|
||||||
|
monkeypatch.setattr("httpx.get", mock_get)
|
||||||
|
|
||||||
|
result = get_daily_summary([w_ok, w_fail], [], date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert abs(result["total_revenue"] - 50.0) < 1e-6
|
||||||
|
assert result["total_cost"] == 0.0
|
||||||
|
assert result["failed_count"] == 1
|
||||||
|
assert result["success"] is False
|
||||||
|
failed = [i for i in result["website_items"] if i["status"] == "failed"]
|
||||||
|
ok = [i for i in result["website_items"] if i["status"] == "success"]
|
||||||
|
assert len(failed) == 1
|
||||||
|
assert len(ok) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_daily_summary_unknown_upstream_is_failed():
|
||||||
|
"""Unknown upstream type must produce status='failed', not silently contribute 0."""
|
||||||
|
u = FakeUpstream(api_prefix="", auth_type="bearer") # unknown type
|
||||||
|
result = get_daily_summary([], [u], date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert result["failed_count"] == 1
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["upstream_items"][0]["status"] == "failed"
|
||||||
|
assert "未知上游类型" in result["upstream_items"][0]["error"]
|
||||||
|
assert result["total_cost"] == 0.0 # unknown upstreams don't contribute
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_daily_summary_all_success(monkeypatch):
|
||||||
|
"""When all items succeed, success=True, totals are correct."""
|
||||||
|
w = FakeWebsite()
|
||||||
|
u = FakeUpstream(api_prefix="api/v1", auth_type="bearer") # sub2api
|
||||||
|
|
||||||
|
call_count = {"n": 0}
|
||||||
|
def mock_get(*a, **kw):
|
||||||
|
call_count["n"] += 1
|
||||||
|
if call_count["n"] == 1:
|
||||||
|
return _make_mock_response({"data": {"total_actual_cost": 100.0}})
|
||||||
|
return _make_mock_response({"data": {"total_actual_cost": 40.0}})
|
||||||
|
monkeypatch.setattr("httpx.get", mock_get)
|
||||||
|
|
||||||
|
result = get_daily_summary([w], [u], date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["failed_count"] == 0
|
||||||
|
assert abs(result["total_revenue"] - 100.0) < 1e-6
|
||||||
|
assert abs(result["total_cost"] - 40.0) < 1e-6
|
||||||
|
assert abs(result["net_income"] - 60.0) < 1e-6
|
||||||
|
assert abs(result["margin_percent"] - 60.0) < 1e-6
|
||||||
@@ -54,6 +54,13 @@
|
|||||||
<small>外部请求耗时、慢接口汇总</small>
|
<small>外部请求耗时、慢接口汇总</small>
|
||||||
</span>
|
</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<router-link to="/finance" class="nav-item" active-class="active" @click="closeMobileNav">
|
||||||
|
<span class="nav-icon"><el-icon><TrendCharts /></el-icon></span>
|
||||||
|
<span class="nav-copy">
|
||||||
|
<strong>财务对账</strong>
|
||||||
|
<small>昨日收入、上游消费、净利润</small>
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -101,6 +108,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
SwitchButton,
|
SwitchButton,
|
||||||
Operation,
|
Operation,
|
||||||
|
TrendCharts,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const router = createRouter({
|
|||||||
{ path: 'logs', component: () => import('@/views/NotificationLogs.vue') },
|
{ path: 'logs', component: () => import('@/views/NotificationLogs.vue') },
|
||||||
{ path: 'external-api-logs', component: () => import('@/views/ExternalApiLogs.vue') },
|
{ path: 'external-api-logs', component: () => import('@/views/ExternalApiLogs.vue') },
|
||||||
{ path: 'custom-pages', component: () => import('@/views/CustomPages.vue') },
|
{ path: 'custom-pages', component: () => import('@/views/CustomPages.vue') },
|
||||||
|
{ path: 'finance', component: () => import('@/views/Finance.vue') },
|
||||||
{ path: 'page/:id', redirect: '/custom-pages' },
|
{ path: 'page/:id', redirect: '/custom-pages' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
<template>
|
||||||
|
<div class="finance-page">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1 class="page-title">
|
||||||
|
<el-icon class="title-icon"><TrendCharts /></el-icon>
|
||||||
|
财务对账
|
||||||
|
</h1>
|
||||||
|
<p class="page-subtitle">实时计算,数据不入库;失败项不计入利润,避免虚高</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="selectedDate"
|
||||||
|
type="date"
|
||||||
|
placeholder="选择日期(默认昨天)"
|
||||||
|
:disabled-date="disabledDate"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
class="date-picker"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" :loading="loading" @click="loadData" class="refresh-btn">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error banner -->
|
||||||
|
<el-alert v-if="fetchError" :title="fetchError" type="error" show-icon :closable="false" class="error-alert" />
|
||||||
|
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<div v-if="summary" class="summary-cards">
|
||||||
|
<div class="stat-card revenue">
|
||||||
|
<div class="stat-label">网站收入</div>
|
||||||
|
<div class="stat-value">{{ formatAmount(summary.total_revenue) }}</div>
|
||||||
|
<div class="stat-sub">{{ summary.date }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card cost">
|
||||||
|
<div class="stat-label">上游消费</div>
|
||||||
|
<div class="stat-value">{{ formatAmount(summary.total_cost) }}</div>
|
||||||
|
<div class="stat-sub">{{ enabledUpstreamCount }} 个上游</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card net" :class="summary.net_income >= 0 ? 'positive' : 'negative'">
|
||||||
|
<div class="stat-label">净收入</div>
|
||||||
|
<div class="stat-value">{{ formatAmount(summary.net_income) }}</div>
|
||||||
|
<div class="stat-sub">毛利率 {{ summary.margin_percent.toFixed(2) }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card failed" :class="summary.failed_count > 0 ? 'has-failures' : ''">
|
||||||
|
<div class="stat-label">失败项</div>
|
||||||
|
<div class="stat-value">{{ summary.failed_count }}</div>
|
||||||
|
<div class="stat-sub">{{ summary.failed_count > 0 ? '部分数据缺失,利润可能偏低' : '全部统计成功' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<el-empty v-else-if="!loading && !fetchError" description="点击刷新加载昨日对账数据" class="empty-state">
|
||||||
|
<el-button type="primary" @click="loadData">立即加载</el-button>
|
||||||
|
</el-empty>
|
||||||
|
|
||||||
|
<el-skeleton v-if="loading" :rows="8" animated class="skeleton" />
|
||||||
|
|
||||||
|
<!-- Tables -->
|
||||||
|
<template v-if="summary && !loading">
|
||||||
|
<!-- Website Revenue Detail -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">
|
||||||
|
<el-icon><OfficeBuilding /></el-icon>
|
||||||
|
网站收入明细
|
||||||
|
</span>
|
||||||
|
<el-tag :type="allWebsitesOk ? 'success' : 'danger'" size="small">
|
||||||
|
{{ summary.website_items.filter(i => i.status === 'success').length }} /
|
||||||
|
{{ summary.website_items.length }} 成功
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-table :data="summary.website_items" class="detail-table" stripe>
|
||||||
|
<el-table-column label="网站名称" prop="name" min-width="180" />
|
||||||
|
<el-table-column label="收入" min-width="140" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.status === 'success'" class="amount-positive">
|
||||||
|
{{ formatAmount(row.amount) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="amount-zero">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
|
||||||
|
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="错误信息" min-width="260">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.error" class="error-text">{{ row.error }}</span>
|
||||||
|
<span v-else class="ok-text">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upstream Cost Detail -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">
|
||||||
|
<el-icon><Connection /></el-icon>
|
||||||
|
上游消费明细
|
||||||
|
</span>
|
||||||
|
<el-tag :type="allUpstreamsOk ? 'success' : 'danger'" size="small">
|
||||||
|
{{ summary.upstream_items.filter(i => i.status === 'success').length }} /
|
||||||
|
{{ summary.upstream_items.length }} 成功
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-table :data="summary.upstream_items" class="detail-table" stripe>
|
||||||
|
<el-table-column label="上游名称" prop="name" min-width="180" />
|
||||||
|
<el-table-column label="类型" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="row.upstream_type === 'sub2api' ? 'primary' : row.upstream_type === 'new_api' ? 'warning' : 'info'"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ row.upstream_type === 'sub2api' ? 'Sub2API' : row.upstream_type === 'new_api' ? 'New-API' : '未知' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="消费" min-width="140" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.status === 'success'" class="amount-cost">
|
||||||
|
{{ formatAmount(row.amount) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="amount-zero">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
|
||||||
|
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="错误信息" min-width="260">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.error" class="error-text">{{ row.error }}</span>
|
||||||
|
<span v-else class="ok-text">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { TrendCharts, Refresh, OfficeBuilding, Connection } from '@element-plus/icons-vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
interface FinanceItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
upstream_type: string
|
||||||
|
amount: number
|
||||||
|
status: 'success' | 'failed'
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailySummary {
|
||||||
|
date: string
|
||||||
|
total_revenue: number
|
||||||
|
total_cost: number
|
||||||
|
net_income: number
|
||||||
|
margin_percent: number
|
||||||
|
website_items: FinanceItem[]
|
||||||
|
upstream_items: FinanceItem[]
|
||||||
|
failed_count: number
|
||||||
|
success: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const fetchError = ref<string | null>(null)
|
||||||
|
const summary = ref<DailySummary | null>(null)
|
||||||
|
const selectedDate = ref<string | null>(null)
|
||||||
|
|
||||||
|
const allWebsitesOk = computed(() => summary.value?.website_items.every(i => i.status === 'success') ?? false)
|
||||||
|
const allUpstreamsOk = computed(() => summary.value?.upstream_items.every(i => i.status === 'success') ?? false)
|
||||||
|
const enabledUpstreamCount = computed(() => summary.value?.upstream_items.length ?? 0)
|
||||||
|
|
||||||
|
function disabledDate(d: Date) {
|
||||||
|
return d > new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(val: number): string {
|
||||||
|
if (val === 0) return '$0.000000'
|
||||||
|
return '$' + val.toFixed(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
fetchError.value = null
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (selectedDate.value) params.set('date', selectedDate.value)
|
||||||
|
const resp = await fetch(`/api/finance/daily-summary?${params}`, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.token}` },
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}))
|
||||||
|
throw new Error(err.detail || `HTTP ${resp.status}`)
|
||||||
|
}
|
||||||
|
const data: DailySummary = await resp.json()
|
||||||
|
summary.value = data
|
||||||
|
if (data.failed_count > 0) {
|
||||||
|
ElMessage.warning(`${data.failed_count} 个数据源统计失败,利润数据可能偏低`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success('对账数据加载成功')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
fetchError.value = e.message || '请求失败'
|
||||||
|
ElMessage.error(fetchError.value ?? '请求失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.finance-page {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.title-icon {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.date-picker { width: 180px; }
|
||||||
|
.refresh-btn { min-width: 90px; }
|
||||||
|
|
||||||
|
/* ── Cards ── */
|
||||||
|
.summary-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.stat-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.08); }
|
||||||
|
.stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stat-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.stat-card.net.positive .stat-value { color: #22c55e; }
|
||||||
|
.stat-card.net.negative .stat-value { color: var(--el-color-danger); }
|
||||||
|
.stat-card.failed.has-failures {
|
||||||
|
border-color: var(--el-color-danger-light-5);
|
||||||
|
background: var(--el-color-danger-light-9);
|
||||||
|
}
|
||||||
|
.stat-card.failed.has-failures .stat-value { color: var(--el-color-danger); }
|
||||||
|
|
||||||
|
/* ── Section ── */
|
||||||
|
.section { margin-bottom: 32px; }
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.detail-table { border-radius: 8px; overflow: hidden; }
|
||||||
|
|
||||||
|
/* ── Amount styles ── */
|
||||||
|
.amount-positive { color: #22c55e; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
.amount-cost { color: var(--el-color-warning); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
.amount-zero { color: var(--el-text-color-placeholder); }
|
||||||
|
|
||||||
|
/* ── Status ── */
|
||||||
|
.error-text { color: var(--el-color-danger); font-size: 12px; word-break: break-all; }
|
||||||
|
.ok-text { color: var(--el-text-color-placeholder); }
|
||||||
|
|
||||||
|
/* ── Misc ── */
|
||||||
|
.error-alert { margin-bottom: 20px; }
|
||||||
|
.skeleton { margin-top: 16px; }
|
||||||
|
.empty-state { margin-top: 60px; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user