Files
saasshop/resources/views/admin/site_subscriptions/index.blade.php

499 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@extends('admin.layouts.app')
@section('title', '订阅管理')
@section('page_title', '订阅管理')
@section('content')
@php
// back 参数用于“返回上一页(保留上下文)”,但 back 本身不应再包含 back避免无限嵌套导致 URL 膨胀)
// 注意:使用相对路径而非绝对 URL避免不同 APP_URL 环境影响,以及 show 页 back 安全校验(要求以 / 开头)
$selfWithoutBack = \App\Support\BackUrl::selfWithoutBack();
$currentQuery = request()->query();
unset($currentQuery['back']);
$back = $selfWithoutBack;
// 用于构建“保留当前筛选上下文”的站内跳转链接(且不透传 back避免嵌套/污染)
$buildSelfUrl = function (array $overrides = []) use ($currentQuery) {
$q = $currentQuery;
foreach ($overrides as $k => $v) {
if ($v === null) {
unset($q[$k]);
} else {
$q[$k] = $v;
}
}
$url = '/' . ltrim(request()->path(), '/');
if (count($q) > 0) {
$url .= '?' . \Illuminate\Support\Arr::query($q);
}
return $url;
};
// back 安全护栏(全页通用):
// - 仅允许站内相对路径(/ 开头)
// - 拒绝引号/尖括号(由于本页大量 href 采用 `{!! !!}` 原样输出,必须严控注入风险)
// - 拒绝 nested back=(避免 URL 膨胀/绕过)
$incomingBack = (string) request()->query('back', '');
$safeBackForLinks = \App\Support\BackUrl::sanitizeForLinks($incomingBack);
// “从订单详情页来挑订阅”的治理交互:
// - attach_order_id表示把选中的订阅绑定回某个订单
// - attach_back绑定成功后回跳到哪里通常是订单详情页
$attachOrderId = (int) request()->query('attach_order_id', 0);
$safeAttachBackForLinks = \App\Support\BackUrl::sanitizeForLinks((string) request()->query('attach_back', ''));
// 用于摘要卡等入口:保留当前 query 并覆盖字段,同时安全透传 back。
$safeFullUrlWithQuery = function (array $overrides = []) use ($safeBackForLinks) {
return \App\Support\BackUrl::currentPathWithQuery($overrides, $safeBackForLinks);
};
$subscriptionIndexUrl = \App\Support\BackUrl::withBack('/admin/site-subscriptions', $safeBackForLinks);
@endphp
<div class="page-header mb-20" data-page="admin.site_subscriptions.index">
<div class="page-header-main">
<div>
<div class="page-header-title">订阅管理</div>
<div class="page-header-subtitle">这里是总台视角的订阅目录页,承接“套餐 -> 订阅 -> 平台订单”的收费主链中间层。当前阶段先做到:可访问列表、可筛选、统计摘要;后续再接:订阅激活服务 / 续费 / 取消 / 对账。</div>
</div>
<div class="page-header-actions">
@if($safeBackForLinks !== '')
<a href="{!! $safeBackForLinks !!}" class="btn btn-secondary btn-sm">返回上一页(保留上下文)</a>
@endif
</div>
</div>
</div>
<div class="card mb-20">
<h3>快捷筛选</h3>
<div class="muted mb-10">用于运营快速定位需要处理的订阅集合(口径基于筛选条件组合)。</div>
@php
// 快捷筛选:仅保留“上下文”字段(站点/套餐/keyword/安全 back避免把其它筛选条件叠加导致空结果
$buildQuickFilterUrl = function (array $overrides) use ($safeBackForLinks) {
return \App\Support\BackUrl::currentPathQuickFilter(['merchant_id', 'plan_id', 'keyword'], $overrides, $safeBackForLinks);
};
// “全部”:清空筛选,但保留安全 back用于返回来源页
$allUrl = \App\Support\BackUrl::withBack('/admin/site-subscriptions', $safeBackForLinks);
@endphp
<div>
<a href="{!! $allUrl !!}" class="muted">全部</a>
<span class="muted"></span>
<a href="{!! $buildQuickFilterUrl(['status' => 'activated', 'expiry' => null]) !!}" class="muted">已生效</a>
<span class="muted"></span>
<a href="{!! $buildQuickFilterUrl(['status' => 'pending', 'expiry' => null]) !!}" class="muted">待生效</a>
<span class="muted"></span>
<a href="{!! $buildQuickFilterUrl(['status' => 'cancelled', 'expiry' => null]) !!}" class="muted">已取消</a>
<span class="muted"></span>
<a href="{!! $buildQuickFilterUrl(['status' => null, 'expiry' => 'expired']) !!}" class="muted">已过期</a>
<span class="muted"></span>
<a href="{!! $buildQuickFilterUrl(['status' => null, 'expiry' => 'expiring_7d']) !!}" class="muted">7天内到期</a>
</div>
</div>
<div class="card mb-20">
<h3>到期治理</h3>
<div class="muted mb-10">按到期时间ends_at快速定位需要续费/处理的订阅集合(不改变订阅 status 字段)。</div>
@if(($filters['expiry'] ?? '') === 'expiring_7d')
<div class="mt-10">
<div class="muted"><strong>7天内到期站点维度提醒清单Top10</strong></div>
<div class="muted muted-xs mt-6">用于运营优先触达按“到期订阅数”排序同时展示最早到期时间min_ends_at</div>
<table class="mt-10" data-role="expiring-7d-merchant-top10">
<thead>
<tr>
<th>站点</th>
<th>到期订阅数</th>
<th>最早到期</th>
</tr>
</thead>
<tbody>
@forelse(($expiryMerchantRows ?? []) as $row)
@php
$mid = (int) ($row['merchant_id'] ?? 0);
$mname = (string) ($row['merchant_name'] ?? '');
$cnt = (int) ($row['count'] ?? 0);
$minEndsAt = (string) ($row['min_ends_at'] ?? '');
$merchantUrl = $buildSelfUrl(['merchant_id' => $mid, 'page' => null]);
@endphp
<tr>
<td><a class="link" href="{!! $merchantUrl !!}">{{ $mname !== '' ? $mname : ('站点#' . $mid) }}</a></td>
<td>{{ $cnt }}</td>
<td>{{ $minEndsAt !== '' ? $minEndsAt : '-' }}</td>
</tr>
@empty
<tr>
<td colspan="3" class="muted">暂无数据</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@endif
@php
$expiredUrl = $buildQuickFilterUrl(['status' => null, 'expiry' => 'expired']);
$expiring7dUrl = $buildQuickFilterUrl(['status' => null, 'expiry' => 'expiring_7d']);
@endphp
<div class="actions">
<a class="btn btn-secondary btn-sm" href="{!! $expiredUrl !!}">已过期({{ $summaryStats['expired_subscriptions'] ?? 0 }}</a>
<a class="btn btn-secondary btn-sm" href="{!! $expiring7dUrl !!}">7天内到期({{ $summaryStats['expiring_7d_subscriptions'] ?? 0 }}</a>
@php
// 当已处于“到期集合”视图时,补一个就近的续费下单入口(带回退到当前列表的 back
$isExpiryView = in_array((string) ($filters['expiry'] ?? ''), ['expired', 'expiring_7d'], true);
$renewalCtaUrl = '';
if ($isExpiryView) {
$q = [
'order_type' => 'renewal',
// 续费单必须绑定订阅:集合入口也应尽可能引导到订阅维度续费(批量另行建设)。
'require_subscription' => '1',
];
if ((int) ($filters['merchant_id'] ?? 0) > 0) {
$q['merchant_id'] = (int) $filters['merchant_id'];
}
if ((int) ($filters['plan_id'] ?? 0) > 0) {
$q['plan_id'] = (int) $filters['plan_id'];
}
$renewalCtaUrl = \App\Support\BackUrl::withBack('/admin/platform-orders/create?' . \Illuminate\Support\Arr::query($q), $selfWithoutBack);
}
@endphp
@if($isExpiryView)
<a class="btn btn-sm" href="{!! $renewalCtaUrl !!}">创建续费订单(当前集合)</a>
<span class="muted muted-xs">(建议:进入后按订阅维度续费)</span>
@endif
</div>
<div class="muted muted-xs mt-6">建议先处理“7天内到期”续费触达再处理“已过期”补单或关闭。</div>
</div>
<details class="card mb-20 collapsible filters-card" data-role="collapsible" data-storage-key="admin.site_subscriptions.filters" open>
<summary class="collapsible-summary filters-summary">
<div class="flex-between items-center">
<h3 class="mb-0">筛选条件</h3>
<span class="muted muted-xs">点击收起/展开</span>
</div>
</summary>
<div class="collapsible-body filters-body">
<form method="get" action="/admin/site-subscriptions" class="grid-4 filters-grid">
@if($safeBackForLinks !== '')
<input type="hidden" name="back" value="{!! $safeBackForLinks !!}">
@endif
<select name="status">
<option value="">全部状态</option>
@foreach(($filterOptions['statuses'] ?? []) as $value => $label)
<option value="{{ $value }}" @selected(($filters['status'] ?? '') === $value)>{{ $label }}</option>
@endforeach
</select>
<select name="expiry">
<option value="">全部到期状态</option>
<option value="expiring_7d" @selected(($filters['expiry'] ?? '') === 'expiring_7d')>7天内到期</option>
<option value="expired" @selected(($filters['expiry'] ?? '') === 'expired')>已过期</option>
</select>
<select name="merchant_id">
<option value="">全部站点</option>
@foreach(($merchants ?? []) as $merchant)
<option value="{{ $merchant->id }}" @selected(($filters['merchant_id'] ?? '') == $merchant->id)>{{ $merchant->name }}</option>
@endforeach
</select>
<select name="plan_id">
<option value="">全部套餐</option>
@foreach(($plans ?? []) as $plan)
<option value="{{ $plan->id }}" @selected(($filters['plan_id'] ?? '') == $plan->id)>{{ $plan->name }}</option>
@endforeach
</select>
<input name="keyword" placeholder="搜索订阅号 / 站点 / 套餐 / 计费周期" value="{{ $filters['keyword'] ?? '' }}">
<div>
<button class="btn btn-sm" type="submit">应用筛选</button>
</div>
</form>
</div>
</details>
<div class="grid-4 mb-20">
<div class="card">
<h3>订阅总数</h3>
<div class="num-md">
<a class="link" href="{!! $subscriptionIndexUrl !!}">{{ $summaryStats['total_subscriptions'] ?? 0 }}</a>
</div>
</div>
<div class="card">
<h3>已生效</h3>
<div class="num-md">
<a class="link" href="{!! $safeFullUrlWithQuery(['status' => 'activated', 'page' => null]) !!}">{{ $summaryStats['activated_subscriptions'] ?? 0 }}</a>
</div>
<div class="muted muted-xs">
<a class="link" href="{!! $safeFullUrlWithQuery(['status' => 'pending', 'page' => null]) !!}">查看待生效</a>
</div>
</div>
<div class="card">
<h3>待生效</h3>
<div class="num-md">
<a class="link" href="{!! $safeFullUrlWithQuery(['status' => 'pending', 'page' => null]) !!}">{{ $summaryStats['pending_subscriptions'] ?? 0 }}</a>
</div>
<div class="muted muted-xs">
<a class="link" href="{!! $safeFullUrlWithQuery(['status' => 'activated', 'page' => null]) !!}">查看已生效</a>
</div>
</div>
<div class="card">
<h3>已取消</h3>
<div class="num-md">
<a class="link" href="{!! $safeFullUrlWithQuery(['status' => 'cancelled', 'page' => null]) !!}">{{ $summaryStats['cancelled_subscriptions'] ?? 0 }}</a>
</div>
<div class="muted muted-xs">
<a class="link" href="{!! $safeFullUrlWithQuery(['status' => 'activated', 'page' => null]) !!}">查看已生效</a>
</div>
</div>
<div class="card">
<h3>已过期(按到期时间)</h3>
<div class="num-md">
<a class="link" href="{!! $safeFullUrlWithQuery(['expiry' => 'expired', 'page' => null]) !!}">{{ $summaryStats['expired_subscriptions'] ?? 0 }}</a>
</div>
<div class="muted muted-xs">
<a class="link" href="{!! $safeFullUrlWithQuery(['expiry' => 'expiring_7d', 'page' => null]) !!}">查看7天内到期</a>
</div>
</div>
<div class="card">
<h3>7天内到期</h3>
<div class="num-md">
<a class="link" href="{!! $safeFullUrlWithQuery(['expiry' => 'expiring_7d', 'page' => null]) !!}">{{ $summaryStats['expiring_7d_subscriptions'] ?? 0 }}</a>
</div>
<div class="muted muted-xs">
<a class="link" href="{!! $safeFullUrlWithQuery(['expiry' => 'expired', 'page' => null]) !!}">查看已过期</a>
</div>
</div>
</div>
<div class="card mb-20">
<h3>工具</h3>
@php
$batchMarkExpiredEnabled = (string) ($filters['expiry'] ?? '') === 'expired';
$batchMarkExpiredReason = $batchMarkExpiredEnabled ? '' : '请先进入「已过期expiry=expired」集合后再执行批量标记。';
@endphp
<div class="grid-2">
<form method="get" action="/admin/site-subscriptions/export" class="actions gap-10">
<input type="hidden" name="download" value="1">
<input type="hidden" name="status" value="{{ $filters['status'] ?? '' }}">
<input type="hidden" name="merchant_id" value="{{ $filters['merchant_id'] ?? '' }}">
<input type="hidden" name="plan_id" value="{{ $filters['plan_id'] ?? '' }}">
<input type="hidden" name="expiry" value="{{ $filters['expiry'] ?? '' }}">
<input type="hidden" name="keyword" value="{{ $filters['keyword'] ?? '' }}">
<button class="btn btn-secondary btn-sm" type="submit">导出 CSV</button>
</form>
@php
$q = [
'order_type' => 'renewal',
'require_subscription' => '1',
];
if ((int) ($filters['merchant_id'] ?? 0) > 0) {
$q['merchant_id'] = (int) $filters['merchant_id'];
}
if ((int) ($filters['plan_id'] ?? 0) > 0) {
$q['plan_id'] = (int) $filters['plan_id'];
}
$createOrderFromSubIndexUrl = \App\Support\BackUrl::withBack('/admin/platform-orders/create?' . \Illuminate\Support\Arr::query($q), $selfWithoutBack);
@endphp
@if(!($isExpiryView ?? false) && $attachOrderId <= 0)
<a class="btn btn-sm" href="{!! $createOrderFromSubIndexUrl !!}">续费下单(先选订阅)</a>
@endif
@if($attachOrderId <= 0)
<form method="post" action="/admin/site-subscriptions/batch-mark-expired" data-action="disable-on-submit" onsubmit="return confirm('确认将当前筛选集合内的订阅批量标记为已过期?该操作会更新 status 字段。');" class="actions gap-10">
@csrf
<input type="hidden" name="status" value="{{ $filters['status'] ?? '' }}">
<input type="hidden" name="merchant_id" value="{{ $filters['merchant_id'] ?? '' }}">
<input type="hidden" name="plan_id" value="{{ $filters['plan_id'] ?? '' }}">
<input type="hidden" name="expiry" value="{{ $filters['expiry'] ?? '' }}">
<input type="hidden" name="keyword" value="{{ $filters['keyword'] ?? '' }}">
<label class="muted form-inline-row">
<span>确认输入</span>
<input type="text" name="confirm" placeholder="YES" class="w-140" @disabled(! $batchMarkExpiredEnabled)>
<span>(必须输入 YES 才会执行)</span>
</label>
<div>
<button class="btn btn-danger btn-sm" type="submit" @disabled(! $batchMarkExpiredEnabled) title="{{ $batchMarkExpiredReason }}">批量标记已过期(当前集合)</button>
@if(! $batchMarkExpiredEnabled)
<div class="adm-tool-blocked-hint">提示:{{ $batchMarkExpiredReason }}</div>
@endif
</div>
</form>
@endif
</div>
<div class="muted muted-xs mt-6">
@if($attachOrderId > 0)
当前为“绑定订阅到订单”模式:请在列表行内直接点击「绑定到订单」。
@else
用于运营从订阅目录快速发起续费下单:会把当前 merchant_id/plan_id 作为默认值带到下单页。
<span class="muted">续费单必须绑定订阅,建议从下方列表行内「续费下单」选择具体订阅。</span>
@endif
@if(($isExpiryView ?? false))
<span class="muted">(已处于到期集合视图:请优先使用上方「创建续费订单(当前集合)」入口)</span>
@endif
</div>
</div>
<div class="card list-card">
<div class="list-card-header">
<div>
<h3 class="list-card-title">订阅列表</h3>
</div>
</div>
<div class="list-card-body">
<table class="list-card-table">
<thead>
<tr>
<th>ID</th>
<th>订阅号</th>
<th>站点</th>
<th>套餐</th>
<th>状态</th>
<th>计费周期</th>
<th>周期()</th>
<th>金额</th>
<th>开始时间</th>
<th>到期时间</th>
<th>到期状态</th>
<th>关联订单数</th>
<th>生效时间</th>
</tr>
</thead>
<tbody>
@forelse($subscriptions as $subscription)
<tr>
<td>{{ $subscription->id }}</td>
<td>
@php
$subShowUrl = \App\Support\BackUrl::withBack('/admin/site-subscriptions/' . $subscription->id, $back);
@endphp
<a href="{!! $subShowUrl !!}">{{ $subscription->subscription_no }}</a>
@php
$remarkPrefix = (string) config('saasshop.platform_orders.renewal_order_remark_prefix', '来自订阅:');
$q = [
'order_type' => 'renewal',
'require_subscription' => '1',
'site_subscription_id' => $subscription->id,
'quantity' => 1,
'remark' => $remarkPrefix . $subscription->subscription_no,
];
if ((int) ($subscription->merchant_id ?? 0) > 0) {
$q['merchant_id'] = (int) $subscription->merchant_id;
}
if ((int) ($subscription->plan_id ?? 0) > 0) {
$q['plan_id'] = (int) $subscription->plan_id;
}
$renewOrderUrl = \App\Support\BackUrl::withBack('/admin/platform-orders/create?' . \Illuminate\Support\Arr::query($q), $back);
@endphp
<div class="mt-4 actions gap-10">
<a class="btn btn-secondary btn-sm" href="{!! $renewOrderUrl !!}">续费下单</a>
@if($attachOrderId > 0)
@php
// 从订单详情进入订阅管理页时:提供“绑定到该订单”的治理按钮
// 注意:提交后由 attachSubscription 做强校验(续费单 + merchant/plan 一致)
$attachBack = $safeAttachBackForLinks !== '' ? $safeAttachBackForLinks : $safeBackForLinks;
if ($attachBack === '') {
$attachBack = $back;
}
@endphp
<form method="post" action="/admin/platform-orders/{{ $attachOrderId }}/attach-subscription" class="inline-form" data-action="disable-on-submit" onsubmit="return confirm('确认将该订阅绑定到目标订单?');">
@csrf
<input type="hidden" name="site_subscription_id" value="{{ $subscription->id }}">
@if($attachBack !== '')
<input type="hidden" name="back" value="{!! $attachBack !!}">
@endif
<button type="submit" class="btn btn-primary btn-sm">绑定到订单 #{{ $attachOrderId }}</button>
</form>
@endif
<form method="post" action="/admin/site-subscriptions/{{ $subscription->id }}/set-status">
@csrf
<select name="status" onchange="this.form.submit()" class="w-140">
@foreach(($filterOptions['statuses'] ?? []) as $value => $label)
<option value="{{ $value }}" @selected($subscription->status === $value)>{{ $label }}</option>
@endforeach
</select>
<noscript><button type="submit" class="btn btn-secondary btn-sm">更新状态</button></noscript>
</form>
</div>
</td>
<td>
@if($subscription->merchant)
<a class="link" href="{!! $buildSelfUrl(['merchant_id' => $subscription->merchant->id, 'page' => null]) !!}">{{ $subscription->merchant->name }}</a>
@else
未关联站点
@endif
</td>
<td>
@if($subscription->plan)
<a class="link" href="{!! $buildSelfUrl(['plan_id' => $subscription->plan->id, 'page' => null]) !!}">{{ $subscription->plan_name ?: $subscription->plan->name }}</a>
@else
{{ $subscription->plan_name ?: '未设置' }}
@endif
</td>
<td>{{ ($statusLabels[$subscription->status] ?? $subscription->status) }} <span class="muted">({{ $subscription->status }})</span></td>
<td>{{ $subscription->billing_cycle ?: '-' }}</td>
<td>{{ $subscription->period_months }}</td>
<td>¥{{ number_format((float) $subscription->amount, 2) }}</td>
<td>{{ optional($subscription->starts_at)->format('Y-m-d H:i:s') ?: '-' }}</td>
<td>{{ optional($subscription->ends_at)->format('Y-m-d H:i:s') ?: '-' }}</td>
<td>
@php
$endsAt = $subscription->ends_at;
$expiryLabel = '无到期';
if ($endsAt) {
if ($endsAt->lt(now())) {
$expiryLabel = '已过期';
} elseif ($endsAt->lt(now()->addDays(7))) {
$expiryLabel = '7天内到期';
} else {
$expiryLabel = '未到期';
}
}
@endphp
{{ $expiryLabel }}
</td>
<td>
@php $cnt = (int) ($subscription->platform_orders_count ?? 0); @endphp
@if($cnt > 0)
@php
// 跳到平台订单页:附带 back 回到“订阅列表页自身(剔除 back并统一走 BackUrl helper。
$platformOrdersUrl = \App\Support\BackUrl::withBack(
'/admin/platform-orders?' . \Illuminate\Support\Arr::query([
'site_subscription_id' => $subscription->id,
]),
$back
);
@endphp
<a href="{!! $platformOrdersUrl !!}">{{ $cnt }}</a>
@else
<span class="muted">0</span>
@endif
</td>
<td>{{ optional($subscription->activated_at)->format('Y-m-d H:i:s') ?: '-' }}</td>
</tr>
@empty
<tr>
<td colspan="13" class="muted table-empty">暂无订阅数据,当前阶段先把订阅主表与总台目录立起来,后续再接订阅创建/激活/续费链路。</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="pagination-wrap">{{ $subscriptions->links('pagination.admin') }}</div>
@endsection