diff --git a/backend/app/main.py b/backend/app/main.py index a5f4fab..6663a9c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,7 +15,7 @@ from app.models.admin_user import AdminUser from app.database import SessionLocal from app.utils.auth import hash_password, verify_password, validate_password_supported 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") logger = logging.getLogger(__name__) @@ -98,6 +98,7 @@ app.include_router(custom_pages.router) app.include_router(websites.router) app.include_router(auth_capture.router) app.include_router(external_api_logs.router) +app.include_router(finance.router) @app.get("/healthz") diff --git a/backend/app/routers/finance.py b/backend/app/routers/finance.py new file mode 100644 index 0000000..f1e14ce --- /dev/null +++ b/backend/app/routers/finance.py @@ -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) diff --git a/backend/app/services/finance_service.py b/backend/app/services/finance_service.py new file mode 100644 index 0000000..f1db83f --- /dev/null +++ b/backend/app/services/finance_service.py @@ -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, + } diff --git a/backend/test_finance_daily_summary.py b/backend/test_finance_daily_summary.py new file mode 100644 index 0000000..cfa6652 --- /dev/null +++ b/backend/test_finance_daily_summary.py @@ -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 diff --git a/frontend/src/components/AppLayout.vue b/frontend/src/components/AppLayout.vue index 46d5bb3..acd54b8 100644 --- a/frontend/src/components/AppLayout.vue +++ b/frontend/src/components/AppLayout.vue @@ -54,6 +54,13 @@ 外部请求耗时、慢接口汇总 + + + + 财务对账 + 昨日收入、上游消费、净利润 + + @@ -101,6 +108,7 @@ import { User, SwitchButton, Operation, + TrendCharts, } from '@element-plus/icons-vue' const router = useRouter() diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 295d1ea..a1a7e92 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -21,6 +21,7 @@ const router = createRouter({ { path: 'logs', component: () => import('@/views/NotificationLogs.vue') }, { path: 'external-api-logs', component: () => import('@/views/ExternalApiLogs.vue') }, { path: 'custom-pages', component: () => import('@/views/CustomPages.vue') }, + { path: 'finance', component: () => import('@/views/Finance.vue') }, { path: 'page/:id', redirect: '/custom-pages' }, ], }, diff --git a/frontend/src/views/Finance.vue b/frontend/src/views/Finance.vue new file mode 100644 index 0000000..763ec2b --- /dev/null +++ b/frontend/src/views/Finance.vue @@ -0,0 +1,345 @@ + + + + +