diff --git a/core/backend/apps/billing/views.py b/core/backend/apps/billing/views.py index e77223c..0264107 100644 --- a/core/backend/apps/billing/views.py +++ b/core/backend/apps/billing/views.py @@ -1,4 +1,5 @@ -from datetime import timedelta +import calendar +from datetime import date, timedelta from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from django.db import transaction @@ -34,6 +35,7 @@ _STAGE_BUCKET = { } + @api_view(["GET"]) @permission_classes([IsAuthenticated]) def summary(request): @@ -229,17 +231,41 @@ def trend(request): peak = max((Decimal(s["amount"]) for s in series), default=Decimal("0")) avg = (total_14d / len(series)).quantize(Decimal("0.0001")) if series else Decimal("0") - # 本月按阶段分布(task.task_type → 4 桶) - month_start = today.replace(day=1) - # 只用 task.task_type/project,defer 掉 task 的大 blob(单条可达 3MB+ base64 图),别把几十 MB 拉回 - month_charges = charges.filter(created_at__date__gte=month_start).select_related("task").defer("task__request_payload", "task__response_payload") + # 按任务类型分布(与后台「任务监控」类型列同源);?month=YYYY-MM 指定月份,默认当月 + # 有扣费的类型全部返回,前端按金额降序全部展示,不截断 + month_param = (request.query_params.get("month") or "").strip() + dist_year, dist_month = today.year, today.month + if len(month_param) == 7 and month_param[4] == "-": + try: + dist_year = int(month_param[:4]) + dist_month = int(month_param[5:7]) + if not (1 <= dist_month <= 12): + raise ValueError("bad month") + except ValueError: + dist_year, dist_month = today.year, today.month + month_param = f"{dist_year:04d}-{dist_month:02d}" + else: + month_param = f"{dist_year:04d}-{dist_month:02d}" + month_start = date(dist_year, dist_month, 1) + last_day = calendar.monthrange(dist_year, dist_month)[1] + month_end = date(dist_year, dist_month, last_day) + month_charges = ( + charges.filter(created_at__date__gte=month_start, created_at__date__lte=month_end) + .select_related("task") + .defer("task__request_payload", "task__response_payload") + ) by_stage = {"script": Decimal("0"), "base": Decimal("0"), "storyboard": Decimal("0"), "video": Decimal("0")} + by_task_type: dict[str, Decimal] = {} project_amounts: dict[str, Decimal] = {} + month_charged = Decimal("0") for row in month_charges: + month_charged += row.amount task = row.task bucket = _STAGE_BUCKET.get(task.task_type) if task else None if bucket: by_stage[bucket] += row.amount + if task and task.task_type: + by_task_type[task.task_type] = by_task_type.get(task.task_type, Decimal("0")) + row.amount pid = str(row.project_id) if row.project_id else None if pid: project_amounts[pid] = project_amounts.get(pid, Decimal("0")) + row.amount @@ -250,7 +276,10 @@ def trend(request): "total_14d": str(total_14d), "avg": str(avg), "peak": str(peak), + "month": month_param, + "month_charged": str(month_charged), "by_stage": {k: str(v) for k, v in by_stage.items()}, + "by_task_type": {k: str(v) for k, v in by_task_type.items()}, "by_project": {k: str(v) for k, v in project_amounts.items()}, } ) diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py index 2780b6f..e158ead 100644 --- a/core/backend/apps/projects/test_quick_create.py +++ b/core/backend/apps/projects/test_quick_create.py @@ -1364,3 +1364,6 @@ class QuickCreateCoordinatorTests(TestCase): + + + diff --git a/core/frontend/src/account-page.css b/core/frontend/src/account-page.css index 4008c37..e867cbc 100644 --- a/core/frontend/src/account-page.css +++ b/core/frontend/src/account-page.css @@ -1,476 +1,871 @@ -/* 账单库 · 影擎没有独立页,按商品库/团队中心的页头、深色指标卡、data-panel 风格排。不改共享 .btn/.pill/.input */ +/* 账户中心 · 对齐设计稿 billing-page 结构(.billing-*),作用域落在 .account-page */ .account-page { --klein: #002fa7; --klein-hover: #002680; + --muted: #6f747c; + --text: #17181a; --st-muted: #6f747c; --st-text: #17181a; - --st-black: #101012; --st-line: rgba(34, 42, 54, 0.10); --st-wash: rgba(34, 42, 54, 0.045); --st-shadow: 0 8px 20px rgba(20, 27, 38, 0.09); + --shadow-card: 0 8px 20px rgba(20, 27, 38, 0.09); width: 100%; max-width: none; box-sizing: border-box; margin: -24px -28px -60px; - padding: 52px clamp(40px, 3.2vw, 68px) 48px; + padding: 52px clamp(40px, 3.2vw, 68px) 54px; .ac-inner { width: min(1560px, 100%); margin: 0 auto; } - .ac-head { + .page-header { min-height: 76px; display: flex; - align-items: flex-start; + align-items: flex-end; justify-content: space-between; gap: 30px; margin-bottom: 34px; } - .ac-head h1 { + + .page-heading h1 { margin: 0 0 10px; font-size: 32px; line-height: 1.2; font-weight: 600; letter-spacing: -.02em; - color: var(--accent-black); + color: var(--accent-black, #17181a); } - .ac-head p { - margin: 0; - color: var(--st-muted); + + .page-heading p { + margin: 7px 0 0; + color: var(--muted); font-size: 15px; } - .ac-top { - display: grid; - grid-template-columns: minmax(0, 1.2fr) minmax(430px, 0.9fr); - gap: 20px; - margin-bottom: 28px; - align-items: stretch; - } - @media (max-width: 1120px) { .ac-top { grid-template-columns: 1fr; } } - - .ac-hero { - min-height: 270px; - display: flex; - flex-direction: column; - justify-content: space-between; - padding: 28px; - border-radius: 14px; - color: #fff; - background: #202124; - box-shadow: 0 14px 30px rgba(16, 16, 18, 0.18); - } - .ac-hero > span, - .ac-hero-stats span, - .ac-progress-meta { - color: rgba(255, 255, 255, 0.58); - font-size: 12px; - } - .ac-hero > strong { - display: block; - margin: 8px 0 6px; - font-size: 38px; - font-weight: 600; - letter-spacing: -.02em; - font-variant-numeric: tabular-nums; - line-height: 1.1; - } - .ac-hero > em { - font-style: normal; - color: rgba(255, 255, 255, 0.46); - font-size: 12px; - } - .ac-hero-stats { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 18px; - margin: 22px 0 18px; - } - .ac-hero-stats strong { - display: block; - margin-top: 6px; - font-size: 19px; - font-weight: 600; - font-variant-numeric: tabular-nums; - } - .ac-progress { - height: 6px; - overflow: hidden; - border-radius: 99px; - background: rgba(255, 255, 255, 0.15); - } - .ac-progress > span { - display: block; - min-width: 7px; - height: 100%; - background: var(--klein); - } - .ac-progress-meta { - display: flex; - justify-content: space-between; - gap: 14px; - margin-top: 10px; + .tag { + display: inline-flex; + align-items: center; + height: 25px; + padding: 0 9px; + border-radius: 7px; + color: #4c5360; + background: rgba(34, 42, 54, 0.08); + font-size: 11px; } - .ac-recharge { - padding: 22px; - border: 1px solid var(--st-line); - border-radius: 14px; - background: var(--st-wash); - box-shadow: var(--st-shadow); - display: flex; - flex-direction: column; - } - .ac-recharge h2 { - margin: 0 0 5px; - font-size: 18px; - font-weight: 600; - color: var(--st-text); - } - .ac-recharge > p { - margin: 0 0 18px; - color: var(--st-muted); - font-size: 12px; - } - .ac-recharge-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 10px; - } - .ac-option { - min-height: 66px; - padding: 10px; + .data-panel { border: 1px solid rgba(34, 42, 54, 0.10); - border-radius: 10px; - background: rgba(34, 42, 54, 0.035); - cursor: pointer; - text-align: center; - position: relative; - } - .ac-option:hover { background: rgba(34, 42, 54, 0.06); } - .ac-option.active { - border-color: var(--klein); - background: rgba(0, 47, 167, 0.08); - } - .ac-option strong, - .ac-option span { display: block; } - .ac-option strong { - font-size: 16px; - font-weight: 600; - color: var(--st-text); - font-variant-numeric: tabular-nums; - } - .ac-option span { - margin-top: 4px; - color: #16a34a; - font-size: 10px; - } - .ac-option span.plain { color: var(--st-muted); } - .ac-option .ribbon { - position: absolute; - top: 6px; - left: 7px; - font-size: 9px; - padding: 1px 5px; - background: var(--klein); - color: #fff; - font-weight: 600; - border-radius: 4px; - } - .ac-pay-title { - margin: 16px 0 8px; - font-size: 12px; - font-weight: 600; - color: var(--st-text); - } - .ac-recharge .input { - width: 100%; - height: 42px; - margin: 0; - padding: 0 13px; - border: 1px solid rgba(34, 42, 54, 0.11); - border-radius: 10px; - background: #fff; - } - .ac-pay-row { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - margin-top: 12px; - } - .ac-pay { - height: 42px; - border: 1px solid rgba(28, 34, 43, 0.12); - border-radius: 11px; - background: #fff; - color: var(--st-text); - display: inline-flex; - justify-content: center; - align-items: center; - gap: 8px; - font: inherit; - font-size: 14px; - cursor: pointer; - } - .ac-pay:hover { background: rgba(34, 42, 54, 0.05); } - .ac-pay .pay-logo { - width: 18px; - height: 18px; - border-radius: 6px; - overflow: hidden; - flex: 0 0 18px; - } - .ac-pay .pay-logo img { width: 100%; height: 100%; display: block; object-fit: cover; } - .ac-tip { - margin-top: 14px; - font-size: 12px; - color: var(--st-muted); - line-height: 1.65; - } - .ac-tip strong { color: var(--st-text); font-weight: 600; } - @media (max-width: 720px) { .ac-recharge-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } - - .pane { - background: var(--st-wash); - border: 1px solid var(--st-line); border-radius: 14px; - padding: 20px; - margin-bottom: 16px; - box-shadow: var(--st-shadow); + background: rgba(34, 42, 54, 0.045); + box-shadow: var(--shadow-card); } - .pane h3 { font-size: 18px; font-weight: 600; margin: 0 0 6px; color: var(--st-text); } - .pane .desc { font-size: 12px; color: var(--st-muted); margin-bottom: 14px; } - .billing-tabs { display: flex; align-items: center; gap: 8px; border-bottom: 0; margin: 0 0 18px; padding: 0; overflow-x: auto; scrollbar-width: none; } - .billing-tabs::-webkit-scrollbar { display: none; } - .ac-tab { - display: inline-flex; - align-items: center; - flex: 0 0 auto; - gap: 6px; - height: 38px; - padding: 0 16px; - border: 0; - border-radius: 9px; - background: transparent; - font: inherit; - font-size: 14px; - font-weight: 500; - color: #50555d; - cursor: pointer; - letter-spacing: 0; - user-select: none; - } - .ac-tab:hover { color: var(--st-text); background: rgba(34, 42, 54, 0.06); } - .ac-tab:focus-visible { outline: none; box-shadow: 0 0 0 2px rgba(0, 47, 167, 0.28); } - .ac-tab.active { color: #fff; background: var(--st-black); font-weight: 500; } - .ac-tab .count { font-family: var(--font-mono); font-size: 10.5px; color: inherit; opacity: .72; padding: 1px 7px; background: rgba(34, 42, 54, 0.08); border-radius: 7px; letter-spacing: .04em; } - .ac-tab.active .count { background: rgba(255, 255, 255, 0.14); color: #fff; opacity: 1; } - - .tab-panel { display: none; } - .tab-panel.active { display: block; } - - .overview-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 16px; align-items: stretch; } - @media (max-width: 980px) { .overview-grid { grid-template-columns: 1fr; } } - - .trend-pane { padding: 0; display: flex; flex-direction: column; overflow: hidden; } - .trend-head { + .panel-head { min-height: 64px; display: flex; align-items: center; - gap: 8px; + justify-content: space-between; + gap: 18px; padding: 0 20px; - margin-bottom: 0; border-bottom: 1px solid rgba(34, 42, 54, 0.08); } - .trend-head h3 { margin-bottom: 0; font-size: 18px; } - .trend-head .sub { font-size: 12px; color: var(--st-muted); } - .trend-head .spacer { flex: 1; } - .ac-seg { - height: 38px; - padding: 0 16px; + + .panel-head h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text); + } + + .panel-head > div:first-child { + display: grid; + gap: 4px; + } + + /* === 设计稿 HTML 原样: stage / usage-line(高度 5px) === */ + .stage-list { + padding: 15px 20px 20px; + } + + .stage-usage { + display: grid; + grid-template-columns: 150px 1fr 86px; + align-items: center; + gap: 14px; + min-height: 48px; + } + + .billing-usage-line { + display: block; + padding: 0; + margin: 0; + font-size: 0; + line-height: 0; + height: 5px; + overflow: hidden; + border-radius: 99px; + background: #dfe2e7; + } + + .billing-usage-line .usage-fill { + display: block; + height: 100%; + border-radius: inherit; + background: #002fa7; + } + + .compact-action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + border-radius: 11px; + cursor: pointer; + border: 1px solid rgba(28, 34, 43, 0.12); + background: rgba(34, 42, 54, 0.05); + color: var(--text); + transition: transform 180ms ease, background-color 180ms ease; + } + + .compact-action:hover { + background: rgba(34, 42, 54, 0.10); + } + + .billing-overview { + display: grid; + grid-template-columns: minmax(280px, 0.82fr) minmax(390px, 1.22fr) minmax(300px, 0.96fr); + gap: 18px; + align-items: stretch; + width: calc(100% + 12px); + margin-left: -6px; + } + + .billing-account-card { + position: relative; + min-height: 286px; + display: flex; + flex-direction: column; + padding: 22px; + overflow: visible; + border-radius: 14px; + color: #fff; + background: + radial-gradient(circle at 88% 9%, rgba(49, 102, 255, 0.34), transparent 31%), + linear-gradient(145deg, #18191c 0%, #22252b 100%); + box-shadow: 0 14px 30px rgba(16, 16, 18, 0.16); + } + + .billing-card-kicker, + .billing-card-actions, + .billing-threshold-row, + .billing-month-control { + display: flex; + align-items: center; + } + + .billing-card-kicker { + justify-content: space-between; + gap: 12px; + color: rgba(255, 255, 255, 0.62); + font-size: 13px; + } + + .billing-card-kicker span:first-child { + color: #fff; + font-weight: 650; + } + + .billing-balance-number { + display: block; + margin-top: 14px; + font-size: 34px; + line-height: 1.08; + letter-spacing: -0.02em; + font-weight: 650; + font-variant-numeric: tabular-nums; + } + + .billing-balance-number small { + margin-left: 5px; + color: rgba(255, 255, 255, 0.62); + font-size: 13px; + font-weight: 500; + } + + .billing-month-control { + justify-content: space-between; + gap: 12px; + margin-top: 22px; + padding: 14px 0; + border-top: 1px solid rgba(255, 255, 255, 0.12); + border-bottom: 1px solid rgba(255, 255, 255, 0.12); + } + + .billing-month-copy span, + .billing-month-copy strong { + display: block; + } + + .billing-month-copy span { + color: rgba(255, 255, 255, 0.56); + font-size: 12px; + } + + .billing-month-copy strong { + margin-top: 5px; + font-size: 19px; + font-weight: 650; + font-variant-numeric: tabular-nums; + } + + .billing-month-picker { + position: relative; + width: 142px; + display: block; + flex: 0 0 auto; + } + + .billing-month-picker .custom-select-shell, + .billing-month-picker .custom-select-trigger { + width: 100%; + } + + .billing-month-picker .custom-select-trigger { + height: 36px; + padding: 0 10px 0 12px; + border-color: rgba(255, 255, 255, 0.2) !important; + border-radius: 9px; + color: #fff !important; + background: #2a2d33 !important; + font-size: 12px; + font-weight: 600; + } + + .billing-month-picker .custom-select-trigger:hover, + .billing-month-picker .custom-select-shell.open .custom-select-trigger { + border-color: rgba(138, 171, 255, 0.72) !important; + color: #fff !important; + background: #323743 !important; + } + + .billing-threshold-row { + justify-content: space-between; + gap: 12px; + margin-top: 14px; + color: rgba(255, 255, 255, 0.66); + font-size: 12px; + } + + .billing-threshold-row label { + display: flex; + align-items: center; + gap: 7px; + } + + .billing-threshold-row svg { + width: 15px; + height: 15px; + color: #8aabff; + } + + .billing-threshold-input { + height: 34px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 9px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 8px; + color: rgba(255, 255, 255, 0.66); + background: rgba(255, 255, 255, 0.07); + transition: border-color .18s ease, background .18s ease; + } + + .billing-threshold-input:focus-within { + border-color: rgba(138, 171, 255, 0.72); + background: rgba(86, 126, 226, 0.14); + } + + .billing-threshold-input input { + width: 72px; + padding: 0; + border: 0; + outline: 0; + color: #fff; + background: transparent; + text-align: right; + font-size: 12px; + font-variant-numeric: tabular-nums; + } + + .billing-threshold-input input::-webkit-outer-spin-button, + .billing-threshold-input input::-webkit-inner-spin-button { + margin: 0; + appearance: none; + } + + .billing-card-actions { + justify-content: space-between; + gap: 10px; + margin-top: auto; + padding-top: 16px; + } + + .billing-balance-state { + display: inline-flex; + align-items: center; + gap: 6px; + color: #b7c9ff; + font-size: 12px; + } + + .billing-balance-state::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: #74a0ff; + box-shadow: 0 0 0 4px rgba(116, 160, 255, 0.12); + } + + .billing-balance-state.is-low { + color: #ffc9a8; + } + + .billing-balance-state.is-low::before { + background: #ff8a4c; + box-shadow: 0 0 0 4px rgba(255, 138, 76, 0.14); + } + + .billing-contact-trigger { + min-width: 112px; + height: 36px; + padding: 0 22px; border: 0; border-radius: 9px; - color: #50555d; - background: transparent; + color: #0b1d4c; + background: #fff; + font-weight: 650; cursor: pointer; - font: inherit; - font-size: 14px; + transition: color .18s ease, background .18s ease, transform .18s ease; } - .ac-seg:hover { background: rgba(34, 42, 54, 0.10); color: var(--st-text); } - .ac-seg.active { color: #fff; background: var(--st-black); } - .ac-summary { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; - padding: 18px 20px 2px; + .billing-contact-trigger:hover { + color: var(--klein); + background: #eef4ff; + transform: translateY(-1px); } - .ac-summary div { - padding: 14px; - border-radius: 11px; - background: rgba(0, 47, 167, 0.055); - } - .ac-summary span, - .ac-summary strong { display: block; } - .ac-summary span { color: var(--st-muted); font-size: 11px; } - .ac-summary strong { margin-top: 7px; font-size: 16px; font-weight: 600; color: var(--st-text); } - .trend-chart { + .billing-chart-panel, + .billing-distribution { position: relative; - height: 220px; + min-height: 286px; + display: grid; + grid-template-rows: auto 1fr; + overflow: hidden; + } + .billing-distribution { + /* 有数据的任务类型可能多达十余项,允许卡片变高,列表可滚动,避免裁切 */ + overflow: visible; + min-height: 286px; + height: auto; + align-content: start; + } + + .billing-chart-panel .panel-head, + .billing-distribution .panel-head { + width: 100%; + align-items: center; + padding-top: 18px; + padding-bottom: 15px; + border-bottom: 0; + } + + .billing-panel-meta { + color: var(--muted); + font-size: 12px; + } + + .billing-chart-summary { display: flex; - align-items: flex-end; + align-items: center; + gap: 8px; + } + + .billing-trend-positive { + color: var(--klein); + font-size: 11px; + font-weight: 650; + white-space: nowrap; + } + + .billing-trend-negative { + color: #c2410c; + font-size: 11px; + font-weight: 650; + white-space: nowrap; + } + + .billing-seven-day-chart { + position: relative; + height: 208px; + display: grid; + grid-template-columns: repeat(7, minmax(32px, 1fr)); + align-items: end; + justify-content: center; gap: 12px; - padding: 30px 28px 22px; - min-height: 0; + padding: 18px 22px 16px; + border-top: 1px solid rgba(34, 42, 54, 0.065); + background: linear-gradient(180deg, rgba(0, 47, 167, 0.018), transparent 48%); } - .trend-chart::after { - content: ""; + + .billing-seven-day-chart::after { + content: ''; position: absolute; - right: 28px; - bottom: 42px; - left: 28px; + left: 22px; + right: 22px; + bottom: 37px; height: 1px; - background: rgba(34, 42, 54, 0.12); + background: rgba(34, 42, 54, 0.16); } - .ac-bar-group { + + .billing-chart-empty, + .billing-dist-empty { + grid-column: 1 / -1; + display: grid; + place-items: center; + color: var(--muted); + font-size: 13px; + min-height: 140px; + } + + .billing-day-bar { position: relative; z-index: 1; height: 100%; - flex: 1; - display: flex; - flex-direction: column; - justify-content: flex-end; - align-items: center; - gap: 9px; - color: var(--st-muted); - font-size: 11px; + display: grid; + grid-template-rows: 22px minmax(92px, 1fr) 22px; + align-items: end; + justify-items: center; min-width: 0; } - .ac-bar { - width: min(30px, 70%); - min-height: 10px; - border-radius: 7px 7px 3px 3px; - background: var(--klein); - opacity: 0.78; + + .billing-day-value { + color: #232936; + font-size: 11px; + font-weight: 650; + font-variant-numeric: tabular-nums; } - .ac-bar-value { - color: #343a45; - font-size: 10px; - font-weight: 600; - } - .ac-bar-group span { overflow: hidden; text-overflow: ellipsis; max-width: 100%; } - .trend-chart-empty { - width: 100%; + + .billing-day-track { + width: 22px; height: 100%; + display: flex; + align-items: flex-end; + overflow: hidden; + border-radius: 7px 7px 3px 3px; + background: rgba(0, 47, 167, 0.055); + } + + .billing-day-track span { + width: 100%; + min-height: 8px; + border-radius: inherit; + background: linear-gradient(180deg, #416fe4 0%, var(--klein) 100%); + transition: height .45s cubic-bezier(.2, .75, .25, 1); + } + + .billing-day-label { + color: var(--muted); + font-size: 11px; + white-space: nowrap; + } + + .billing-distribution .panel-head { + padding-bottom: 11px; + } + + /* === 设计稿 .billing-distribution 内 stage 覆盖(原样) === */ + .billing-distribution .stage-list { + display: grid; + grid-template-columns: 1fr; + align-content: center; + row-gap: 2px; + width: 100%; + padding: 4px 20px 13px; + max-height: min(480px, 58vh); + overflow-y: auto; + scrollbar-width: thin; + } + + .billing-distribution .stage-usage { + display: grid; + grid-template-columns: 88px minmax(72px, 1fr) 64px; + align-items: center; + min-height: 34px; + gap: 9px; + font-size: 12px; + } + + .billing-distribution .stage-usage > span:first-child { + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .billing-distribution .stage-usage strong { + justify-self: end; + font-variant-numeric: tabular-nums; + font-weight: 650; + } + + .billing-ledger { + width: calc(100% + 12px); + margin-top: 20px; + margin-left: -6px; + overflow: visible; + } + + .billing-ledger .panel-head { + padding-top: 12px; + padding-bottom: 12px; + align-items: center; + } + + .billing-ledger-toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; + } + + .billing-ledger-toolbar .billing-ledger-filter, + .billing-ledger-toolbar .custom-select-shell { + width: 136px; + flex: 0 0 136px; + } + + .billing-ledger-toolbar .custom-select-trigger { + height: 38px; + padding: 0 11px; + border-radius: 9px; + font-size: 12px; + } + + .billing-ledger-export { + width: 136px; + height: 38px; + justify-content: center !important; + border-color: var(--klein) !important; + color: #fff !important; + background: var(--klein) !important; + font-size: 11px; + font-weight: 650; + } + + .billing-ledger-export:hover { + border-color: var(--klein-hover) !important; + background: var(--klein-hover) !important; + } + + .billing-ledger-export svg { + width: 15px; + height: 15px; + } + + .billing-ledger-list { + display: grid; + } + + .billing-ledger-row { + display: grid; + grid-template-columns: 162px minmax(320px, 1fr) 150px 118px; + align-items: center; + gap: 18px; + min-height: 74px; + padding: 12px 20px; + border-top: 1px solid rgba(34, 42, 54, 0.075); + background: rgba(255, 255, 255, 0.5); + } + + .billing-ledger-row:hover { + background: rgba(0, 47, 167, 0.035); + } + + .billing-ledger-row.header { + min-height: 42px; + padding-top: 8px; + padding-bottom: 8px; + color: var(--muted); + font-size: 11px; + background: rgba(34, 42, 54, 0.025); + } + + .billing-ledger-feature { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + } + + .billing-ledger-icon { + width: 34px; + height: 34px; display: grid; place-items: center; - color: var(--st-muted); + flex: 0 0 auto; + border-radius: 9px; + color: var(--klein); + background: rgba(0, 47, 167, 0.08); + } + + .billing-ledger-icon svg { + width: 17px; + height: 17px; + } + + .billing-ledger-copy, + .billing-ledger-meta { + min-width: 0; + } + + .billing-ledger-copy strong, + .billing-ledger-copy span, + .billing-ledger-meta strong, + .billing-ledger-meta span { + display: block; + } + + .billing-ledger-copy strong, + .billing-ledger-meta strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-size: 13px; } - .trend-foot { display: flex; gap: 14px; margin: 0 20px 16px; padding-top: 10px; border-top: 1px solid var(--st-line); font-size: 12px; } - .trend-foot .item { display: flex; align-items: baseline; gap: 6px; } - .trend-foot .item .k { color: var(--st-muted); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; } - .trend-foot .item .v { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--st-text); } - .trend-foot .item .v.warn { color: var(--accent-honey); } - .stage-pane .usage-line { display: flex; justify-content: space-between; padding: 4px 0 4px; font-size: 13px; } - .stage-pane .usage-line .k { color: var(--st-text); } - .stage-pane .usage-line .v { font-variant-numeric: tabular-nums; color: var(--st-text); font-weight: 600; } - .stage-pane .usage-bar { height: 4px; background: rgba(34, 42, 54, 0.08); border-radius: 2px; margin: 4px 0 10px; overflow: hidden; } - .stage-pane .usage-bar > span { display: block; height: 100%; transition: width .3s ease; } - .stage-pane .total { display: flex; justify-content: space-between; padding-top: 10px; margin-top: 6px; border-top: 1px solid var(--st-line); font-size: 13px; font-weight: 600; } - .stage-pane .total .v { font-variant-numeric: tabular-nums; } + .billing-ledger-copy span, + .billing-ledger-meta span { + margin-top: 5px; + color: var(--muted); + font-size: 11px; + } - .rule-pane .rule-list { font-size: 13px; color: var(--st-muted); line-height: 1.7; } - .rule-pane .rule-list strong { color: var(--st-text); font-weight: 600; } - .rule-pane .mono-acc { font-family: var(--font-mono); color: var(--klein); background: rgba(0, 47, 167, 0.09); padding: 1px 5px; font-size: 11.5px; border-radius: var(--r-sm); } + .billing-ledger-cost { + justify-self: end; + color: #111827; + font-size: 15px; + font-variant-numeric: tabular-nums; + } - .quota-rules { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--st-line); } - .quota-rules .qr-head { font-family: var(--font-mono); font-size: 10.5px; color: var(--st-muted); letter-spacing: .04em; text-transform: uppercase; margin-bottom: 10px; } - .quota-rules .step { display: grid; grid-template-columns: 22px 1fr; gap: 10px; align-items: baseline; margin-bottom: 6px; font-size: 13px; color: var(--st-text); } - .quota-rules .step .num { width: 20px; height: 20px; border-radius: 50%; background: rgba(0, 47, 167, 0.09); color: var(--klein); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; display: grid; place-items: center; } - .quota-rules .step .formula { font-family: var(--font-mono); font-size: 11.5px; color: var(--klein); background: rgba(0, 47, 167, 0.09); padding: 0 4px; border-radius: var(--r-sm); } + .billing-ledger-cost.is-credit { + color: #138d3f; + } - .billing-table { width: 100%; border-collapse: separate; border-spacing: 0; background: #fff; border: 1px solid var(--st-line); border-radius: 14px; overflow: hidden; box-shadow: var(--st-shadow); } - .billing-table th, .billing-table td { padding: 13px 16px; text-align: left; font-size: 13px; border-bottom: 0; } - .billing-table thead th { background: var(--st-wash); border-bottom: 1px solid var(--st-line); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; color: var(--st-muted); letter-spacing: .04em; text-transform: uppercase; } - .billing-table tbody tr:hover { background: var(--st-wash); } - .billing-table .ts { font-family: var(--font-mono); font-size: 11px; color: var(--st-muted); letter-spacing: .02em; } - .billing-table .neg { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--st-text); text-align: right; } - .billing-table .pos { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-forest); text-align: right; } - .billing-table .zero { font-variant-numeric: tabular-nums; font-weight: 500; color: rgba(28, 34, 43, 0.28); text-align: right; } - .billing-table .quota { font-variant-numeric: tabular-nums; text-align: left; } - .billing-table .quota .used { font-weight: 500; color: var(--st-text); } - .billing-table .quota .lim { color: rgba(28, 34, 43, 0.32); } - .billing-table .muted { color: var(--st-muted); font-size: 12px; } - .billing-table .sys { font-family: var(--font-mono); font-size: 10.5px; color: rgba(28, 34, 43, 0.32); } - .billing-table .ref { color: var(--st-muted); font-size: 10.5px; font-family: var(--font-mono); } - .billing-table .who { display: inline-flex; align-items: center; gap: 8px; } - .billing-table .who .av { width: 24px; height: 24px; border-radius: 50%; background: var(--st-wash); border: 1px solid var(--st-line); display: inline-grid; place-items: center; font-size: 12px; font-weight: 600; color: var(--st-text); } - .billing-table .role-pill { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: var(--r-pill); font-size: 10.5px; font-weight: 500; } - .billing-table .role-pill .dot { width: 5px; height: 5px; border-radius: 50%; } - .billing-table .role-super { background: rgba(0, 47, 167, 0.09); color: var(--klein); } - .billing-table .role-super .dot { background: var(--klein); } - .billing-table .role-admin { background: rgba(22, 184, 78, 0.10); color: #138d3f; } - .billing-table .role-admin .dot { background: #138d3f; } - .billing-table .role-member { background: var(--st-wash); color: var(--st-muted); } - .billing-table .role-member .dot { background: var(--st-muted); } - .billing-table .status-tag { font-family: var(--font-mono); font-size: 10px; padding: 1px 6px; border-radius: var(--r-sm); letter-spacing: .04em; } - .billing-table .status-tag.ok { background: rgba(66,195,102,.12); color: var(--accent-forest); } - .billing-table .status-tag.wip { background: rgba(0, 47, 167, 0.09); color: var(--klein); } - .billing-table .status-tag.fail { background: rgba(235,52,36,.10); color: var(--accent-crimson); } + .billing-ledger-empty { + min-height: 210px; + display: grid; + place-items: center; + align-content: center; + gap: 8px; + border-top: 1px solid rgba(34, 42, 54, 0.075); + color: var(--muted); + text-align: center; + } - .bill-pager { display: flex; align-items: center; gap: 12px; margin-top: 14px; font-size: 12px; } - .bill-pager .total { font-family: var(--font-mono); font-size: 12px; color: var(--st-muted); letter-spacing: .02em; } - .bill-pager .pages { display: inline-flex; gap: 4px; margin-left: auto; } - .bill-pager .pages button { min-width: 28px; height: 28px; padding: 0 8px; border: 1px solid var(--st-line); border-radius: var(--r-sm); background: #fff; font-family: var(--font-mono); font-size: 12px; color: var(--st-muted); cursor: pointer; } - .bill-pager .pages button:hover:not(.active):not(:disabled) { border-color: rgba(28, 34, 43, 0.22); color: var(--st-text); } - .bill-pager .pages button.active { background: var(--st-black); color: #fff; border-color: var(--st-black); font-weight: 600; } - .bill-pager .pages button:disabled { opacity: .4; cursor: not-allowed; } - .bill-pager .pages .ellipsis { min-width: 20px; height: 28px; display: inline-flex; align-items: flex-end; justify-content: center; padding-bottom: 4px; color: rgba(28, 34, 43, 0.28); font-family: var(--font-mono); font-size: 12px; user-select: none; } - .bill-pager .bill-jump { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 12px; color: var(--st-muted); } - .bill-pager .bill-jump input { width: 48px; height: 28px; padding: 0 6px; border: 1px solid var(--st-line); border-radius: var(--r-sm); background: #fff; font-family: var(--font-mono); font-size: 12px; color: var(--st-text); text-align: center; } - .bill-pager .bill-jump input:focus { outline: none; border-color: var(--klein); } - .bill-pager .bill-jump button { min-width: 40px; height: 28px; padding: 0 10px; border: 1px solid var(--st-line); border-radius: var(--r-sm); background: #fff; font-family: var(--font-mono); font-size: 12px; color: var(--st-muted); cursor: pointer; } - .bill-pager .bill-jump button:hover:not(:disabled) { border-color: rgba(28, 34, 43, 0.22); color: var(--st-text); } - .bill-pager .bill-jump button:disabled { opacity: .4; cursor: not-allowed; } - .billing-table .bill-detail { max-width: 320px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .billing-table .bill-status { white-space: nowrap; } - .billing-table .progress-mini { width: 80px; height: 4px; background: rgba(34, 42, 54, 0.08); border-radius: 2px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 8px; } - .billing-table .progress-mini > span { display: block; height: 100%; background: var(--klein); } + .billing-ledger-empty svg { + width: 28px; + height: 28px; + color: rgba(0, 47, 167, 0.42); + } - .filter-bar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; } - .filter-bar select, .filter-bar input { background: #fff; border: 1px solid var(--st-line); border-radius: 8px; padding: 6px 10px; font-size: 13px; font-family: inherit; color: var(--st-text); } - .filter-bar .rs-select-btn { background: #fff; border-color: var(--st-line); } - .filter-bar select:hover, .filter-bar input:hover { border-color: rgba(28, 34, 43, 0.18); } - .filter-bar select { padding-right: 24px; } - .filter-bar .spacer { flex: 1; } - .filter-bar .ct { font-family: var(--font-mono); font-size: 11px; color: var(--st-muted); letter-spacing: .02em; } - .filter-bar .ct b { color: var(--st-text); font-weight: 600; } - .filter-reset { height: 30px; padding: 0 10px; border: 1px solid var(--st-line); border-radius: 8px; background: #fff; font-family: var(--font-mono); font-size: 11px; color: var(--st-muted); letter-spacing: .02em; cursor: pointer; } - .filter-reset:hover { border-color: rgba(28, 34, 43, 0.18); color: var(--st-text); background: var(--st-wash); } + .billing-ledger-empty strong { + color: var(--text); + font-size: 14px; + } - .billing-table .proj-name { font-weight: 500; color: var(--st-text); } + .billing-ledger-empty span { + font-size: 12px; + } - .topup-modal { width: min(460px, 92vw); } - .topup-modal .topup-info { text-align: center; font-size: 13px; color: var(--st-muted); margin-bottom: 4px; } - .topup-modal .topup-amt { text-align: center; font-size: 26px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--klein); margin-bottom: 6px; line-height: 1.1; } - .topup-modal .topup-note { text-align: center; font-family: var(--font-mono); font-size: 11px; color: var(--st-muted); letter-spacing: .02em; margin-bottom: 14px; } - .topup-modal .topup-qr { aspect-ratio: 1; max-width: 200px; margin: 0 auto 12px; background: var(--st-wash); border: 1px solid var(--st-line); border-radius: var(--r-md); display: grid; place-items: center; position: relative; overflow: hidden; } - .topup-modal .topup-qr::before { - content: ''; position: absolute; inset: 16px; - background-image: linear-gradient(45deg, var(--st-black) 25%, transparent 25%), - linear-gradient(-45deg, var(--st-black) 25%, transparent 25%), - linear-gradient(45deg, transparent 75%, var(--st-black) 75%), - linear-gradient(-45deg, transparent 75%, var(--st-black) 75%); - background-size: 16px 16px; - background-position: 0 0, 0 8px, 8px -8px, -8px 0px; - opacity: .12; + .bill-pager { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + padding: 14px 20px 18px; + border-top: 1px solid rgba(34, 42, 54, 0.075); + } + .bill-pager .total { + color: var(--muted); + font-size: 12px; + } + .bill-pager .pages { + display: flex; + align-items: center; + gap: 6px; + } + .bill-pager .pages button { + min-width: 32px; + height: 32px; + padding: 0 8px; + border: 1px solid rgba(34, 42, 54, 0.12); + border-radius: 8px; + background: #fff; + color: var(--text); + font-size: 12px; + cursor: pointer; + } + .bill-pager .pages button.active { + border-color: var(--klein); + color: #fff; + background: var(--klein); + } + .bill-pager .pages button:disabled { + opacity: 0.4; + cursor: not-allowed; + } + .bill-pager .ellipsis { + color: var(--muted); + padding: 0 2px; + } + .bill-jump { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + color: var(--muted); + font-size: 12px; + } + .bill-jump input { + width: 56px; + height: 32px; + padding: 0 8px; + border: 1px solid rgba(34, 42, 54, 0.12); + border-radius: 8px; + text-align: center; + } + .bill-jump button { + height: 32px; + padding: 0 12px; + border: 1px solid rgba(34, 42, 54, 0.12); + border-radius: 8px; + background: #fff; + cursor: pointer; + } + .bill-jump button:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + @media (max-width: 1200px) { + .billing-overview { + grid-template-columns: 1fr; + } + .billing-ledger-row { + grid-template-columns: 140px minmax(200px, 1fr) 120px 90px; + } + } + + @media (max-width: 860px) { + .billing-ledger-row { + grid-template-columns: 1fr; + gap: 8px; + padding: 14px 16px; + } + .billing-ledger-row.header { display: none; } + .billing-ledger-cost { justify-self: start; } + .billing-ledger-toolbar { width: 100%; } } - .topup-modal .topup-qr .center { position: relative; z-index: 1; background: #fff; padding: 10px 12px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: 11px; color: var(--st-muted); letter-spacing: .02em; text-align: center; line-height: 1.6; } - .topup-modal .topup-qr .ref { color: rgba(28, 34, 43, 0.32); } - .topup-modal .topup-valid { text-align: center; font-family: var(--font-mono); font-size: 11px; color: var(--st-muted); letter-spacing: .02em; } +} + +/* 充值弹窗(portal 到 body) */ +.billing-contact-backdrop { + position: fixed; + inset: 0; + z-index: 900; + display: grid; + place-items: center; + padding: 24px; + opacity: 0; + visibility: hidden; + background: rgba(15, 18, 24, 0.28); + backdrop-filter: blur(5px); + transition: opacity .2s ease, visibility .2s ease; +} +.billing-contact-backdrop.active { + opacity: 1; + visibility: visible; +} +.billing-contact-dialog { + width: min(420px, 100%); + padding: 24px; + border: 1px solid rgba(34, 42, 54, 0.11); + border-radius: 16px; + background: #fff; + box-shadow: 0 24px 70px rgba(18, 25, 38, 0.18); + transform: translateY(10px) scale(.985); + transition: transform .2s ease; +} +.billing-contact-backdrop.active .billing-contact-dialog { + transform: translateY(0) scale(1); +} +.billing-contact-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} +.billing-contact-head h2 { + margin: 0; + font-size: 20px; +} +.billing-contact-head p { + margin: 7px 0 0; + color: #6f747c; + font-size: 13px; + line-height: 1.6; +} +.billing-contact-close { + width: 34px; + height: 34px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid rgba(34, 42, 54, 0.1); + border-radius: 9px; + color: #20242c; + background: #fff; + cursor: pointer; +} +.billing-contact-close:hover { + color: #002fa7; + background: #eef4ff; +} +.billing-contact-card { + display: grid; + gap: 11px; + margin-top: 20px; + padding: 16px; + border: 1px solid rgba(0, 47, 167, 0.12); + border-radius: 12px; + background: rgba(0, 47, 167, 0.045); +} +.billing-contact-line { + display: grid; + grid-template-columns: 92px 1fr; + gap: 12px; + font-size: 13px; +} +.billing-contact-line span { + color: #6f747c; +} +.billing-contact-line strong { + color: #161a22; + font-weight: 650; } diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 8e5e84f..92eade8 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -943,8 +943,12 @@ export const api = { if (filters?.user) q.set("user", filters.user); return request<{ count: number; page: number; page_size: number; results: Ledger[] }>(`/api/billing/ledgers/?${q.toString()}`); }, - billingTrend(range?: "day" | "week" | "month") { - return request(`/api/billing/trend/${range ? `?range=${range}` : ""}`); + billingTrend(range?: "day" | "week" | "month", month?: string) { + const q = new URLSearchParams(); + if (range) q.set("range", range); + if (month) q.set("month", month); + const qs = q.toString(); + return request(`/api/billing/trend/${qs ? `?${qs}` : ""}`); }, modelConfigs() { // 创作页下拉依赖完整 active 目录;加大 page_size 避免默认分页截断 diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index fafb5a4..b29a9df 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -1,6 +1,6 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { AlertCircle, Boxes, Check, ChevronDown, ChevronRight, Info, LogOut, Settings, UsersRound } from "lucide-react"; +import { AlertCircle, Boxes, Check, ChevronDown, ChevronRight, Info, LogOut, Settings, WalletCards } from "lucide-react"; import { IconKitSvg } from "./IconKitSvg"; import { ConfirmModal, useBodyScrollLock, useOverlayTransition } from "./overlays"; import type { Product, Project, Team, User } from "../types"; @@ -22,7 +22,7 @@ const SHELL_COMMANDS: Command[] = [ { id: "video-replace", group: "导航", label: "视频复刻", sub: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容", page: "videoReplace", icon: "replace", key: "E" }, { id: "library", group: "导航", label: "成品库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" }, { id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" }, - { id: "account", group: "导航", label: "账单库", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" }, + { id: "account", group: "导航", label: "账户中心", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" }, { id: "settings", group: "导航", label: "系统设置", sub: "个人信息、通知、安全、偏好", page: "settings", icon: "settings" }, { id: "messages", group: "常用动作", label: "消息中心", sub: "任务提醒、协作评论、系统通知", page: "messages", icon: "bell", key: "M" }, { id: "new-product", group: "常用动作", label: "新建商品", sub: "从商品信息开始生成素材与视频", page: "productCreateUpload", icon: "productPlus" }, @@ -114,7 +114,7 @@ const ACCOUNT_ITEMS: { act: Page; icon: string; label: string }[] = [ { act: "settings", icon: "settings", label: "个人设置" }, { act: "messages", icon: "bell", label: "消息中心" }, { act: "team", icon: "users", label: "团队管理" }, - { act: "account", icon: "creditCard", label: "账单库" } + { act: "account", icon: "creditCard", label: "账户中心" } ]; export function AccountMenu({ open, anchorRect, onClose, navigate, logout, user, team, canManageBilling = true }: { @@ -231,7 +231,7 @@ const NAV: NavDef[] = [ { id: "free-create", page: "freeCreate", label: "自由创作", icon: "film" }, { id: "library", page: "library", label: "成品库", icon: "library" }, { id: "team", page: "team", label: "团队", icon: "users" }, - { id: "account", page: "account", label: "账单库", icon: "creditCard" }, + { id: "account", page: "account", label: "账户中心", icon: "creditCard" }, { id: "trash", page: "trash", label: "垃圾桶", icon: "trash" }, { id: "settings", page: "settings", label: "系统设置", icon: "settings" } ]; @@ -239,20 +239,19 @@ const RESOURCE_NAV: Array<{ id: string; page: Page; label: string }> = [ { id: "products", page: "products", label: "商品库" }, { id: "models", page: "models", label: "模特库" }, { id: "library", page: "library", label: "成品库" }, - { id: "account", page: "account", label: "账单库" }, { id: "trash", page: "trash", label: "垃圾桶" }, ]; -type MajorNav = "resource" | "team" | "settings"; -const MAJOR_META: Record = { +type MajorNav = "resource" | "account" | "settings"; +const MAJOR_META: Record = { resource: { label: "资源中心", icon: "boxes" }, - team: { label: "团队中心", icon: "users-round" }, + account: { label: "账户中心", icon: "wallet-cards" }, settings: { label: "系统设置", icon: "settings" }, }; -function LiquidIcon({ name }: { name: "boxes" | "users-round" | "settings" }) { +function LiquidIcon({ name }: { name: "boxes" | "wallet-cards" | "settings" }) { if (name === "boxes") return ; - if (name === "users-round") return ; + if (name === "wallet-cards") return ; return ; } @@ -441,7 +440,7 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p const resourceNavItems = RESOURCE_NAV.filter((item) => navItems.some((nav) => nav.page === item.page)); const topModule = topModuleForPage(page); const activeMajor: MajorNav | null = - activeNav === "team" ? "team" + activeNav === "account" ? "account" : activeNav === "settings" ? "settings" : topModule ? null : resourceNavItems.some((item) => item.page === activeNav) ? "resource" @@ -477,7 +476,7 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p const resourceOpen = activeMajor === "resource" && resourcePinned; const resourceItemRef = useRef(null); - const teamItemRef = useRef(null); + const accountItemRef = useRef(null); const settingsItemRef = useRef(null); const submenuRef = useRef(null); const prevMajorRef = useRef(activeMajor); @@ -486,7 +485,7 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p useLayoutEffect(() => { const item = activeMajor === "resource" ? resourceItemRef.current - : activeMajor === "team" ? teamItemRef.current + : activeMajor === "account" ? accountItemRef.current : activeMajor === "settings" ? settingsItemRef.current : null; const leavingResource = prevMajorRef.current === "resource" && activeMajor !== "resource"; @@ -520,7 +519,7 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p const onResize = () => { const current = activeMajor === "resource" ? resourceItemRef.current - : activeMajor === "team" ? teamItemRef.current + : activeMajor === "account" ? accountItemRef.current : activeMajor === "settings" ? settingsItemRef.current : null; if (!current) return; @@ -638,14 +637,14 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p {canManageBilling && ( )} diff --git a/core/frontend/src/routes/account.tsx b/core/frontend/src/routes/account.tsx index 92d4c6e..d7dff0f 100644 --- a/core/frontend/src/routes/account.tsx +++ b/core/frontend/src/routes/account.tsx @@ -1,48 +1,18 @@ import { useEffect, useMemo, useState } from "react"; -import { CreditCard, X } from "lucide-react"; +import { + BellRing, Clapperboard, Download, Film, Image as ImageIcon, + ReceiptText, Replace, ScanText, WandSparkles, X, +} from "lucide-react"; import { createPortal } from "react-dom"; import { api } from "../api"; import type { BillingSummary, BillingTrend, Ledger, Project, Team, TeamMember } from "../types"; import type { NavigateFn } from "./route-config"; -import { money, pts, stageMeta, yuan } from "./stage-config"; +import { pts } from "./stage-config"; import { pageWindow } from "../components/pager"; import { useBodyScrollLock, useOverlayTransition } from "../components/overlays"; import { CustomSelect } from "../components/custom-select"; -const ROLE_LABEL: Record = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" }; -const STATUS_LABEL: Record = { active: "活跃", invited: "待激活", disabled: "已停用" }; -// 角色 pill 样式键(对齐 V1 .role-super / .role-admin / .role-member) -const ROLE_PILL: Record = { owner: "role-super", admin: "role-admin", member: "role-member", viewer: "role-member" }; -type TrendRange = "day" | "week" | "month"; -const RANGE_META: Record = { - day: { chip: "日", sub: "近 14 天 · 单位 积分", totalLabel: "14 天合计", avgLabel: "日均" }, - week: { chip: "周", sub: "近 8 周 · 单位 积分", totalLabel: "8 周合计", avgLabel: "周均" }, - month: { chip: "月", sub: "近 6 个月 · 单位 积分", totalLabel: "6 月合计", avgLabel: "月均" } -}; - -type Tab = "overview" | "by-project" | "by-member" | "bills"; -type TopupQuote = { amount: number; bonus: number; channel: "wechat" | "alipay" }; - -// 项目状态 → 三态(对齐 V1 进行中 / 已完成 / 失败 · 待重跑) -function projBucket(p: Project): "wip" | "ok" | "fail" { - if (p.status === "completed") return "ok"; - if (p.status === "failed") return "fail"; - return "wip"; -} -const PROJ_STATUS_LABEL: Record<"wip" | "ok" | "fail", string> = { wip: "进行中", ok: "已完成", fail: "失败 · 待重跑" }; -// 阶段 → 编号 + 名称 + 进度百分比(5 段流水线;completed = 100%) -// 故事板已下线;storyboard 保留映射只为老项目的历史 current_stage 兜底 -const STAGE_PCT: Record = { script: 25, base_assets: 50, storyboard: 50, video: 75, export: 100 }; -function projStage(p: Project): { label: string; pct: number } { - if (p.status === "completed") return { label: "Stage 5 拼接导出", pct: 100 }; - const meta = stageMeta[p.current_stage]; - const no = meta?.no ?? "1"; - const label = meta ? `Stage ${no} ${meta.label}` : p.current_stage || "脚本"; - return { label, pct: STAGE_PCT[p.current_stage] ?? 20 }; -} - -// 账单类型 / 详情 中文化(后端历史英文流水也一并映射;未知值原样透出) const LEDGER_TYPE_LABEL: Record = { recharge: "充值", reserve: "预扣", release: "释放", charge: "扣费", adjustment: "调整", refund: "退款" }; @@ -56,61 +26,109 @@ const LEDGER_REASON_LABEL: Record = { const ledgerTypeLabel = (t: string) => LEDGER_TYPE_LABEL[t] ?? t; const ledgerReasonLabel = (r: string) => LEDGER_REASON_LABEL[r] ?? r; -// 积分制:amt 是真实支付 ¥,到账积分 = ¥×10;bonusAmt 是**赠送积分**(等值维持原 ¥ 赠送 ×10) -const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [ - { amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 }, - { amt: 500, gift: "+300 积分赠送", bonus: true, bonusAmt: 300, ribbon: "推荐" }, - { amt: 1000, gift: "+800 积分赠送", bonus: true, bonusAmt: 800 }, - { amt: 3000, gift: "+3000 积分赠送", bonus: true, bonusAmt: 3000 } -]; -const MIN_RECHARGE_AMOUNT = 50; +const THRESHOLD_KEY = "airshelf:billing-balance-threshold"; -const STAGES: Array<{ k: string; short: string; color: string; bucket: keyof BillingTrend["by_stage"] }> = [ - { k: "视频片段(Seedance)", short: "视频生成", color: "var(--klein)", bucket: "video" }, - { k: "故事板(image-2)", short: "故事板", color: "var(--accent-forest)", bucket: "storyboard" }, - { k: "基础资产", short: "基础资产", color: "var(--black-alpha-56)", bucket: "base" }, - { k: "脚本 LLM", short: "脚本生成", color: "var(--black-alpha-32)", bucket: "script" } -]; +// 与后台「任务监控」类型列同源(AITask.task_type);展示中文名,数值按类型聚合 +const TASK_TYPE_LABEL: Record = { + script_generation: "脚本生成", + script_optimization: "脚本优化", + entity_extraction: "实体提取", + video_digest: "视频提炼", + product_image: "商品图", + person_image: "人物图", + model_triview: "模特三视图", + scene_image: "场景图", + storyboard: "故事板", + video_segment: "视频片段", + voiceover: "配音", + export: "导出", + free_video: "自由视频", +}; +const taskTypeLabel = (t: string) => TASK_TYPE_LABEL[t] || t; -// 充值扫码弹窗 · 复用设计系统 .modal-bg/.modal 外壳(QR 占位 + 金额 + 赠送 + 5 分钟有效 + 双按钮) -// 关闭与进出场走 overlays 的 useOverlayTransition(ESC / 点遮罩关),完成支付回调交父级弹全局 toast。 -function TopupModal({ open, channel, amount, bonus, rate, close, onDone }: { - open: boolean; - channel: "wechat" | "alipay"; - amount: number; - bonus: number; - rate: number; // 积分汇率(积分/¥),来自 /api/billing/config/,与后端到账口径同源 - close: () => void; - onDone: () => void | Promise; -}) { +function FeatureIcon({ name }: { name: string }) { + const props = { size: 17, strokeWidth: 2 } as const; + switch (name) { + case "clapperboard": return ; + case "wand": return ; + case "replace": return ; + case "film": return ; + case "scan": return ; + case "image": return ; + default: return ; + } +} + +function featureIconForLedger(ledger: Ledger): string { + const text = `${ledger.ledger_type} ${ledger.reason}`.toLowerCase(); + if (text.includes("image") || text.includes("图片")) return "image"; + if (text.includes("digest") || text.includes("提示词") || text.includes("提炼")) return "scan"; + if (text.includes("replace") || text.includes("remix") || text.includes("复刻")) return "replace"; + if (text.includes("free") || text.includes("自由") || text.includes("film")) return "film"; + if (text.includes("quick") || text.includes("一键") || text.includes("wand")) return "wand"; + return "clapperboard"; +} + +function formatMonthLabel(ym: string) { + const [y, m] = ym.split("-"); + return `${y}年${Number(m)}月`; +} + +function monthOptions(count = 4) { + const now = new Date(); + const out: Array<{ value: string; label: string }> = []; + for (let i = 0; i < count; i++) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + const value = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + out.push({ value, label: formatMonthLabel(value) }); + } + return out; +} + +function formatLedgerTime(iso: string) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + const hh = String(d.getHours()).padStart(2, "0"); + const mi = String(d.getMinutes()).padStart(2, "0"); + return `${mm}/${dd} ${hh}:${mi}`; +} + +function formatDayLabel(dateStr: string, fallback: string) { + // trend.daily.date 多为 YYYY-MM-DD;label 可能已是短标签 + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(dateStr); + if (m) return `${m[2]}/${m[3]}`; + return fallback || dateStr; +} + +// 联系充值弹窗(对齐设计稿:对公转账联系财务) +function ContactRechargeModal({ open, close }: { open: boolean; close: () => void }) { useBodyScrollLock(open); const { mounted, show } = useOverlayTransition(open, close); if (!mounted) return null; - const channelLabel = channel === "alipay" ? "支付宝" : "微信支付"; - const scanLabel = channel === "alipay" ? "支付宝扫码" : "微信扫码"; - const txRef = `/topup/${channel === "alipay" ? "ali" : "wx"}/TX${Math.floor(Date.now() / 1000)}`; return createPortal( -
-
event.stopPropagation()}> - ++ -
-
-
扫码支付{channelLabel}
- -
-
-
支付金额
-
{yuan(amount)}
-
{`到账 ${money(Math.round(amount * rate + bonus))}`}{bonus > 0 ? `(含 ${bonus} 积分赠送)` : ""}
-
-
{scanLabel}
{txRef}
+
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="billingContactTitle" + > +
+
+

联系充值

+

平台当前通过对公转账充值,请联系财务确认金额与到账信息。

-
5 分钟内有效 · 到账后自动关闭
-
积分不可提现、不可转让 · 长期有效 · 发票按实际支付金额开具
+
-
- - +
+
财务联系人专属客户经理
+
联系电话待平台配置
+
企业微信待平台配置
, @@ -118,52 +136,45 @@ function TopupModal({ open, channel, amount, bonus, rate, close, onDone }: { ); } -export function AccountPage({ billing, projects, team, onRecharge, onNotify }: { +export function AccountPage({ billing, projects: _projects, team, onRecharge: _onRecharge, onNotify: _onNotify }: { billing: BillingSummary | null; projects: Project[]; - // team prop 用于读取团队级月限额(与团队管理页保持同一来源 · #14) team?: Team | null; navigate: NavigateFn; onRecharge: (amount: number, bonus: number) => void | Promise; onNotify: (type: "success" | "error" | "info", text: string) => void; }) { - const [tab, setTab] = useState("overview"); - // 积分汇率(积分/¥):预览换算与后端 recharge 同源,汇率改配后不至于「预览 ×10 实到 ×N」 - const [pointsRate, setPointsRate] = useState(10); - useEffect(() => { - void api.billingConfig().then((cfg) => setPointsRate(Number(cfg.points_per_yuan) || 10)).catch(() => undefined); - }, []); - const [recharge, setRecharge] = useState(500); - const [customAmt, setCustomAmt] = useState(""); - // 账户页数据本页自取(不再走全局 bootstrap):充值后 bump 刷新流水/趋势 + const months = useMemo(() => monthOptions(4), []); + const [selectedMonth, setSelectedMonth] = useState(months[0]?.value ?? ""); + const [ledgerMonth, setLedgerMonth] = useState(months[0]?.value ?? ""); + const [featureFilter, setFeatureFilter] = useState("all"); + const [threshold, setThreshold] = useState(() => { + const saved = Number(localStorage.getItem(THRESHOLD_KEY)); + return Number.isFinite(saved) && saved > 0 ? saved : 100000; + }); + const [rechargeOpen, setRechargeOpen] = useState(false); + const [reloadFlag, setReloadFlag] = useState(0); - // 充值扫码弹窗:点支付方式先弹弹窗,弹窗内「已完成支付」才真正提交 - const [topupChannel, setTopupChannel] = useState<"wechat" | "alipay" | null>(null); - // 弹窗退场动画期间仍会保留在 DOM 中;打开时冻结本次支付快照,避免清空自定义金额后回显为预选套餐。 - const [topupQuote, setTopupQuote] = useState(null); - - // 趋势(day,首屏)+ 团队成员(成员/限额 tab)本页懒加载 const [trend, setTrend] = useState(null); const [teamMembers, setTeamMembers] = useState([]); - // 团队级月限额本页自取最新值:team prop 来自登录时 /me/ 快照,团队页改完月限额后不刷新会陈旧, - // 造成本页「本月限额」与团队页「月限额」对不上(R72) const [freshTeam, setFreshTeam] = useState(null); useEffect(() => { let alive = true; - api.billingTrend().then((d) => { if (alive) setTrend(d); }).catch(() => {}); + api.billingTrend(undefined, selectedMonth || undefined).then((d) => { if (alive) setTrend(d); }).catch(() => {}); + return () => { alive = false; }; + }, [reloadFlag, selectedMonth]); + + useEffect(() => { + let alive = true; api.teamMembers().then((m) => { if (alive) setTeamMembers(m); }).catch(() => {}); api.teamSettings().then((t) => { if (alive) setFreshTeam(t); }).catch(() => {}); return () => { alive = false; }; }, [reloadFlag]); - // 账单流水分页:服务端分页(总数随流水增长,不再写死 100),每页 10 条 const BILLS_PER_PAGE = 10; const [billPage, setBillPage] = useState(1); - // 账单流水筛选(类型 + 成员):传参给后端做全量过滤(R70)。 - // 原实现只在前端过滤当前页 10 条,导致「扣费/充值」数据不全、总页数共用全部类型的 count。 const [billType, setBillType] = useState("all"); - const [billMember, setBillMember] = useState("all"); const [ledgerRows, setLedgerRows] = useState([]); const [ledgerCount, setLedgerCount] = useState(0); const [ledgersLoading, setLedgersLoading] = useState(true); @@ -172,113 +183,81 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: { setLedgersLoading(true); api.ledgers(billPage, BILLS_PER_PAGE, { ledger_type: billType === "all" ? undefined : billType, - user: billMember === "all" ? undefined : billMember }).then((data) => { if (!alive) return; setLedgerRows(data.results); setLedgerCount(data.count); }).catch(() => {}).finally(() => { if (alive) setLedgersLoading(false); }); return () => { alive = false; }; - }, [billPage, billType, billMember, reloadFlag]); - // row40:跳页输入框 —— 输入页码回车/点「跳转」即钳到 [1,总页数] 并翻页 + }, [billPage, billType, reloadFlag]); + const [billJump, setBillJump] = useState(""); - const selectedCard = RECHARGE.find((item) => item.amt === recharge); - const customAmount = Number(customAmt); - const hasCustomAmount = customAmt.length > 0; - const customAmountIsValid = !hasCustomAmount || (Number.isFinite(customAmount) && customAmount >= MIN_RECHARGE_AMOUNT); - const effectiveAmount = hasCustomAmount ? customAmount : recharge; - const effectiveBonus = hasCustomAmount ? 0 : selectedCard?.bonusAmt || 0; - const setCustomIntegerAmount = (value: string) => setCustomAmt(value.replace(/\D/g, "").replace(/^0+/, "")); - - function validateCustomAmount() { - if (customAmountIsValid) return true; - onNotify("error", `最小充值金额为 ${MIN_RECHARGE_AMOUNT} 元`); - return false; - } - - function openTopup(channel: "wechat" | "alipay") { - if (!validateCustomAmount()) return; - setTopupQuote({ amount: effectiveAmount, bonus: effectiveBonus, channel }); - setTopupChannel(channel); - } - - async function submitRecharge() { - if (!topupQuote || topupQuote.amount < MIN_RECHARGE_AMOUNT) { - onNotify("error", `最小充值金额为 ${MIN_RECHARGE_AMOUNT} 元`); - setTopupChannel(null); - return; - } - await onRecharge(topupQuote.amount, topupQuote.bonus); // 父级 action 成功后弹全局「充值成功」toast - setTopupChannel(null); - setCustomAmt(""); - setBillPage(1); - setReloadFlag((n) => n + 1); // 充值后刷新流水/趋势(余额由全局 action→loadData 刷新) - } - const balance = Number(billing?.account.balance || 0); const used = Number(billing?.charged_total || 0); - // 月限额:与团队管理页保持同一来源(team.monthly_credit_limit) · #14 - // 优先用本页刚拉的最新团队设置,fallback 到 team prop(R72:prop 是 /me/ 快照,改完限额不刷新会陈旧) - // -1 = 不限(显示余额);0/null = 未设置 → 用成员额度累加,再 fallback 余额 const teamData = freshTeam ?? team; const savedMonthlyLimit = teamData?.monthly_credit_limit == null ? 0 : Number(teamData.monthly_credit_limit); const memberLimitSum = teamMembers.reduce((sum, m) => sum + Math.max(0, Number(m.monthly_credit_limit || 0)), 0); const limit = savedMonthlyLimit === -1 ? balance : (savedMonthlyLimit > 0 ? savedMonthlyLimit : (memberLimitSum || balance)); - const left = Math.max(0, limit - used); - const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0; + void limit; // 保留限额计算,设计稿本页不展示进度条 + + const balanceOk = balance >= threshold; + const balanceState = balanceOk ? "当前余额充足" : "余额低于提醒阈值"; - // 消费趋势(日/周/月可切)+ 按阶段 / 按项目分布 —— 全部来自真实 CHARGE 流水 - // day 用首屏 prop;week/month 切换时按真接口拉对应区间(缺接口已补 ?range=) - const [range, setRange] = useState("day"); - const [rangeTrend, setRangeTrend] = useState(null); useEffect(() => { - if (range === "day") { setRangeTrend(null); return; } - let alive = true; - api.billingTrend(range).then((data) => { if (alive) setRangeTrend(data); }).catch(() => {}); - return () => { alive = false; }; - }, [range]); - const activeTrend = range === "day" ? trend : rangeTrend; - const meta = RANGE_META[range]; + localStorage.setItem(THRESHOLD_KEY, String(threshold)); + }, [threshold]); - const daily = activeTrend?.daily ?? []; - const peak = Number(activeTrend?.peak || 0); - const total14 = Number(activeTrend?.total_14d ?? used); - const avgValue = daily.length ? total14 / daily.length : 0; - const stageTotal = STAGES.reduce((sum, s) => sum + Number(trend?.by_stage[s.bucket] || 0), 0) || used; - const topStage = STAGES.reduce((best, s) => { - const amt = Number(trend?.by_stage[s.bucket] || 0); - return amt > best.amt ? { label: s.short, amt } : best; - }, { label: "暂无消耗", amt: 0 }); - const topPct = stageTotal > 0 ? Math.round((topStage.amt / stageTotal) * 100) : 0; - const projectSpend = (id: string) => Number(trend?.by_project[id] || 0); + // 近七日消耗 + const sevenDay = useMemo(() => { + const daily = trend?.daily ?? []; + const slice = daily.slice(-7); + const peak = Math.max(0, ...slice.map((d) => Number(d.amount) || 0)); + const total = slice.reduce((s, d) => s + (Number(d.amount) || 0), 0); + // 较前 7 日:若有 14 天数据则对比前半段 + let trendPct: number | null = null; + if (daily.length >= 14) { + const prev = daily.slice(-14, -7); + const prevTotal = prev.reduce((s, d) => s + (Number(d.amount) || 0), 0); + if (prevTotal > 0) trendPct = Math.round(((total - prevTotal) / prevTotal) * 100); + } + return { slice, peak, total, trendPct }; + }, [trend]); - // ── 项目筛选(状态;真实数据无 owner/per-range,故只做状态过滤)── - const [projStatus, setProjStatus] = useState<"all" | "wip" | "ok" | "fail">("all"); - const filteredProjects = useMemo( - () => projects.filter((p) => projStatus === "all" || projBucket(p) === projStatus), - [projects, projStatus] - ); - const projSpendSum = filteredProjects.reduce((s, p) => s + projectSpend(p.id), 0); - const projFiltered = projStatus !== "all"; + // 积分消耗分布:查询月内有扣费的 task_type 全部展示(不截断),金额降序 + const distribution = useMemo(() => { + const by = trend?.by_task_type || {}; + const rows = Object.entries(by) + .map(([type, amount]) => ({ type, label: taskTypeLabel(type), amount: Number(amount) || 0 })) + .filter((r) => r.amount > 0) + .sort((a, b) => b.amount - a.amount); + const total = rows.reduce((s, r) => s + r.amount, 0); + const max = Math.max(0, ...rows.map((r) => r.amount), 0); + return { rows, total, max }; + }, [trend]); - // ── 成员筛选(角色)── - const [memRole, setMemRole] = useState<"all" | "owner" | "admin" | "member">("all"); - const filteredMembers = useMemo( - () => teamMembers.filter((m) => memRole === "all" || m.role === memRole), - [teamMembers, memRole] - ); - const memUsedSum = filteredMembers.reduce((s, m) => s + Number(m.month_charged || 0), 0); - const memFiltered = memRole !== "all"; + // 流水:类型筛映射 ledger_type;月份按 created_at 过滤当前页 + const LEDGER_TYPE_FILTER: Record = { + all: "all", + charge: "charge", + recharge: "recharge", + reserve: "reserve", + release: "release", + adjustment: "adjustment", + refund: "refund", + }; + + useEffect(() => { + const next = LEDGER_TYPE_FILTER[featureFilter] ?? "all"; + setBillType(next); + setBillPage(1); + }, [featureFilter]); + + const visibleLedgers = useMemo(() => { + if (!ledgerMonth) return ledgerRows; + return ledgerRows.filter((l) => (l.created_at || "").startsWith(ledgerMonth)); + }, [ledgerRows, ledgerMonth]); - // ── 账单流水筛选辅助 ── 筛选本体在上方 useEffect 走后端全量过滤(R70)。 - // 成员下拉选项取团队成员表(原先取自当前页流水 user_label,只能看到本页出现过的人);value 传 user id 给后端。 - const billMemberOptions = useMemo( - () => teamMembers.map((m) => ({ id: m.user.id, label: m.user.username || m.user.email || "成员" })), - [teamMembers] - ); - const billFiltered = billType !== "all" || billMember !== "all"; - // 服务端过滤后 count 即当前筛选自己的总数,总页数随筛选变化(R70) const billTotalPages = Math.max(1, Math.ceil(ledgerCount / BILLS_PER_PAGE)); const safeBillPage = Math.min(billPage, billTotalPages); function gotoBillPage() { @@ -287,372 +266,279 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: { setBillJump(""); } + function exportLedgers() { + if (visibleLedgers.length === 0) { + onNotify("info", "当前没有可导出的流水"); + return; + } + const header = ["功能", "消耗位置", "成员", "时间", "消耗积分"]; + const lines = visibleLedgers.map((l) => { + const amt = Number(l.amount); + const signed = (amt > 0 && l.ledger_type === "recharge") || l.ledger_type === "release" || l.ledger_type === "refund" + ? `+${pts(Math.abs(amt))}` + : `-${pts(Math.abs(amt))}`; + return [ + ledgerTypeLabel(l.ledger_type), + ledgerReasonLabel(l.reason).replace(/,/g, ","), + l.user_label || "系统", + formatLedgerTime(l.created_at), + signed, + ].join(","); + }); + const blob = new Blob([[header.join(","), ...lines].join("\n")], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `积分消耗流水-${ledgerMonth || "all"}.csv`; + a.click(); + URL.revokeObjectURL(url); + onNotify("success", "已导出积分消耗流水"); + } + + const monthUsageDisplay = trend?.month_charged != null ? Number(trend.month_charged) : (selectedMonth === months[0]?.value ? used : null); + return ( -
+
-
-
-

账单库

-

余额、充值与流水

-
-
+
+
+

账户中心

+

查看积分余额、消耗趋势与使用明细

+
+
-
-
-
- 团队余额 - {money(balance)} - 充值累加 · 不重置 -
-
-
- 本月限额 - {money(limit)} +
+
+
+ 当前积分余额 + 积分账户
-
- 当月已用 - {money(used)} -
-
-
-
- 0 ? 2 : 0)}%` }} /> -
-
- 团队月剩余 {money(left)} - 使用率 {pct.toFixed(1)}% -
-
-
+ + {pts(balance)}积分 + -
-

快速充值

-

充值后立刻到账,可开发票 · 仅超管可操作 · 已选 ¥{effectiveAmount}(到账 {Math.round(effectiveAmount * pointsRate + effectiveBonus)} 积分){effectiveBonus > 0 ? ` · 含 ${effectiveBonus} 积分赠送` : ""}

-
- {RECHARGE.map((item) => ( - - ))} -
-
自定义金额
- setCustomIntegerAmount(event.target.value)} - /> - {!customAmountIsValid && } -
- - -
-
- 计费说明 - 1 元 = 10 积分。脚本/文案 10 积分/次;图片生成 20 积分/张;配音 10 积分/500 字; - 视频按时长与分辨率计量。生成失败、超时均不扣费。 -
-
-
- -
- - - - -
- -
-
-
-
-

消费趋势

- {meta.sub} - - {(["day", "week", "month"] as TrendRange[]).map((r) => ( - - ))} -
-
-
- 本月已用 - {money(used)} +
+
+ {formatMonthLabel(selectedMonth)}已消耗 + + {monthUsageDisplay == null ? "—" : `${pts(monthUsageDisplay)} 积分`} +
-
- 本月剩余 - {money(left)} -
-
- 主要消耗 - {topStage.amt > 0 ? `${topStage.label} ${topPct}%` : "暂无消耗"} + +
+ +
+ +
+ 低于 + { + const n = Number(e.target.value); + if (Number.isFinite(n) && n > 0) setThreshold(Math.floor(n)); + }} + /> + 积分
-
- {daily.length === 0 ? ( -
暂无消费记录
- ) : daily.map((d) => { - const amt = Number(d.amount); - const h = peak > 0 ? Math.max(amt > 0 ? 8 : 4, (amt / peak) * 100) : 8; + +
+ {balanceState} + +
+
+ +
+
+
+

近七日消耗

+ 每日更新,仅保留最近 7 天 +
+
+ {sevenDay.trendPct != null && ( + = 0 ? "billing-trend-positive" : "billing-trend-negative"}> + 较前7日 {sevenDay.trendPct >= 0 ? "+" : ""}{sevenDay.trendPct}% + + )} + 共 {pts(sevenDay.total)} 积分 +
+
+
+ {sevenDay.slice.length === 0 ? ( +
暂无消耗记录
+ ) : sevenDay.slice.map((d) => { + const amt = Number(d.amount) || 0; + const h = sevenDay.peak > 0 ? Math.max(amt > 0 ? 8 : 4, Math.round((amt / sevenDay.peak) * 100)) : 8; return ( -
- {amt > 0 ? Math.round(amt) : ""} -
- {d.label} +
+ {amt > 0 ? pts(Math.round(amt)) : "0"} +
+ {formatDayLabel(d.date, d.label)}
); })}
-
-
{meta.totalLabel}{money(total14)}
-
{meta.avgLabel}{money(avgValue)}
-
峰值{money(peak)}
+
+ +
+
+
+

积分消耗分布

+ 按任务类型统计{formatMonthLabel(selectedMonth)}消耗 +
+ {pts(distribution.total)} 积分 +
+
+ {distribution.rows.length === 0 ? ( +
暂无任务类型消耗
+ ) : distribution.rows.map((r) => { + // 相对本月最大项 = 100%,不做 4% 保底(否则 120/100/10 会看起来一样长) + const w = distribution.max > 0 ? Math.min(100, Math.round((r.amount / distribution.max) * 100)) : 0; + return ( +
+ {r.label} +
+ +
+ {Number(r.amount).toLocaleString("zh-CN")} +
+ ); + })} +
+
+
+ +
+
+
+

积分消耗流水

+ 每次积分消耗均可追溯到具体任务 +
+
+ + { setLedgerMonth(v); setBillPage(1); }} + options={months.map((m) => ({ value: m.value, label: m.label }))} + /> +
-
-

本月按阶段分布

-
PRD §5.3.5 扣费规则 · 仅确认后扣
- {STAGES.map((s) => { - const amt = Number(trend?.by_stage[s.bucket] || 0); - const w = stageTotal > 0 ? Math.min(100, (amt / stageTotal) * 100) : 0; +
+
+ 功能 + 消耗位置 + 成员与时间 + 消耗积分 +
+ + {ledgersLoading && visibleLedgers.length === 0 ? ( +
+ + 加载中… + 正在拉取积分消耗流水 +
+ ) : visibleLedgers.length === 0 ? ( +
+ + 暂无积分消耗 + 当前月份和功能下没有匹配的流水记录 +
+ ) : visibleLedgers.map((l) => { + const amt = Number(l.amount) || 0; + const isCredit = l.ledger_type === "recharge" || l.ledger_type === "release" || l.ledger_type === "refund" || (amt > 0 && l.ledger_type !== "charge" && l.ledger_type !== "reserve"); + const costText = `${isCredit ? "+" : "−"}${pts(Math.abs(amt))}`; return ( -
-
{s.k}{money(amt)}
-
+
+
+ + {ledgerTypeLabel(l.ledger_type)} +
+
+ {ledgerReasonLabel(l.reason)} + {l.ledger_type === "recharge" ? "账户充值" : "积分变动 · 可追溯流水"} +
+
+ {l.user_label || "系统"} + {formatLedgerTime(l.created_at)} +
+ {costText}
); })} -
合计{money(used)}
-
-
-

扣费 + 四层额度预检规则

-
PRD §5.3.5 + §10.3 · 对接团队请以此页为准
-
- ① 失败不扣:模型超时 / 内容审核拦截 / 生成异常一律不扣费。
- ② 用户重跑不扣首次:第一次重跑保留原扣费,第二次起按次结算。
- ③ 仅在你点击 [ 确认通过 ] 时入账
- ④ 导出不再扣费,所有 token 已在过程中结算。 -
-
-
任务确认前 · 四层额度预检(任一不通过即拦截)
-
1个人日剩余 ≥ 任务预估 × 1.2
-
2个人月剩余 ≥ 同上
-
3团队月剩余 ≥ 同上
-
4团队总余额 ≥ 同上
-
-
-
- -
-
- { setBillType(next); setBillPage(1); }} - options={[ - { value: "all", label: "全部类型" }, - { value: "charge", label: "扣费" }, - { value: "recharge", label: "充值" }, - { value: "reserve", label: "预扣" }, - { value: "release", label: "释放" }, - { value: "adjustment", label: "调整" }, - { value: "refund", label: "退款" }, - ]} - /> - { setBillMember(next); setBillPage(1); }} - options={[ - { value: "all", label: "全部成员" }, - ...billMemberOptions.map((m) => ({ value: m.id, label: m.label })), - ]} - /> - {billFiltered && ( - - )} - - 本页 {ledgerRows.length} 条 · 共 {ledgerCount} 条 -
-
- - - - {ledgersLoading && ledgerRows.length === 0 ? ( - - ) : ledgerRows.length === 0 ? ( - - ) : ledgerRows.map((l) => ( - - - - - - - - - ))} - -
时间项目 / 类型详情成员状态金额
加载中…
{billFiltered ? "无匹配筛选条件的账单" : "暂无账单流水"}
{new Date(l.created_at).toLocaleString("zh-CN")}{ledgerTypeLabel(l.ledger_type)}{ledgerReasonLabel(l.reason)}{l.user_label - ? {l.user_label.slice(0, 1).toUpperCase()}{l.user_label} - : 系统}成功{pts(l.amount)}
-
- {ledgerCount > BILLS_PER_PAGE && ( -
- 共 {ledgerCount} 条 · 第 {safeBillPage} / {billTotalPages} 页 -
- - {pageWindow(safeBillPage, billTotalPages).map((p, i) => ( - p === "ellipsis" - ? - : - ))} - + {ledgerCount > BILLS_PER_PAGE && ( +
+ 共 {ledgerCount} 条 · 第 {safeBillPage} / {billTotalPages} 页 +
+ + {pageWindow(safeBillPage, billTotalPages).map((p, i) => ( + p === "ellipsis" + ? + : + ))} + +
+ + 跳至 + setBillJump(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") gotoBillPage(); }} + aria-label="跳至页码" + /> + 页 + +
- {/* row40:跳页输入 —— 输入页码回车或点「跳转」直达 */} - - 跳至 - setBillJump(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") gotoBillPage(); }} - aria-label="跳至页码" - /> - 页 - - -
- )} -
- -
-
- setProjStatus(next as typeof projStatus)} - options={[ - { value: "all", label: "全部状态" }, - { value: "wip", label: "进行中" }, - { value: "ok", label: "已完成" }, - { value: "fail", label: "失败 · 待重跑" }, - ]} - /> - {projFiltered && ( - )} - - {filteredProjects.length} 个项目 · 消耗 {money(projSpendSum)} -
-
- - - - {filteredProjects.length === 0 ? ( - - ) : filteredProjects.map((p) => { - const spend = projectSpend(p.id); - const stg = projStage(p); - const bucket = projBucket(p); - return ( - - - - - - - - ); - })} - -
项目商品当前阶段状态消耗
当前筛选条件下没有项目
{p.name}{p.product_title || "-"}{stg.label}{PROJ_STATUS_LABEL[bucket]} 0 ? "neg" : "zero"}>{money(spend)}
-
+
-
-
- setMemRole(next as typeof memRole)} - options={[ - { value: "all", label: "全部角色" }, - { value: "owner", label: "超管" }, - { value: "admin", label: "团管" }, - { value: "member", label: "成员" }, - ]} - /> - {memFiltered && ( - - )} - - {filteredMembers.length} 人 · 合计 {money(memUsedSum)} -
-
- - - - {filteredMembers.length === 0 ? ( - - ) : filteredMembers.map((m) => { - const monthly = Number(m.monthly_credit_limit || 0); - const monthUsed = Number(m.month_charged || 0); - const usedPct = monthly > 0 ? Math.min(100, (monthUsed / monthly) * 100) : 0; - const rolePill = ROLE_PILL[m.role] || "role-member"; - return ( - - - - - - - ); - })} - -
成员角色已用 / 月度额度状态
当前筛选条件下没有成员
{m.user.username.slice(0, 1).toUpperCase()}{m.user.username}{ROLE_LABEL[m.role] || m.role} - {money(monthUsed)} / {monthly > 0 ? money(monthly) : "不限"}{monthly > 0 ? ` · ${usedPct.toFixed(1)}%` : ""} - {monthly > 0 && ( - = 85 ? "var(--accent-honey)" : "var(--klein)" }} /> - )} - {STATUS_LABEL[m.status] || m.status}
-
-
-
- - setTopupChannel(null)} - onDone={submitRecharge} + setRechargeOpen(false)} /> ); diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index 1b4c45b..75fceb5 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -2571,7 +2571,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
预估扣费 ≈ 20 积分/张 - 余额以账单库为准 + 余额以账户中心为准