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

282 lines
12 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 膨胀)
$currentQuery = request()->query();
unset($currentQuery['back']);
$selfWithoutBack = '/' . ltrim(request()->path(), '/');
if (count($currentQuery) > 0) {
$selfWithoutBack .= '?' . \Illuminate\Support\Arr::query($currentQuery);
}
// 用于构建“从套餐页跳转到订阅/订单页后可返回套餐页”的链接
$makeSubscriptionUrl = function (array $query) use ($selfWithoutBack) {
$query = $query + ['back' => $selfWithoutBack];
return '/admin/site-subscriptions?' . \Illuminate\Support\Arr::query($query);
};
$makePlatformOrderUrl = function (array $query) use ($selfWithoutBack) {
$query = $query + ['back' => $selfWithoutBack];
return '/admin/platform-orders?' . \Illuminate\Support\Arr::query($query);
};
@endphp
<div class="card mb-20">
<p class="muted muted-tight">这里是总台视角的套餐目录页,用于沉淀平台可售卖的标准能力包。</p>
<p class="muted">当前阶段先完成套餐主数据可见、可筛与口径收拢,后续再接授权项、售价规则与上下架动作。</p>
@php
$incomingBack = (string) request()->query('back', '');
// 为避免 & 被 Blade escape 成 &amp; 导致回退上下文丢失,这里需要原样输出 href。
// 安全护栏:必须为站内相对路径,并拒绝引号/尖括号,降低 XSS 风险。
$safeBack = (str_starts_with($incomingBack, '/')
&& !preg_match('/["\'<>]/', $incomingBack)
// back 本身不应再包含 back避免无限嵌套导致 URL 膨胀)
&& !preg_match('/(?:^|[?&])back=/', $incomingBack))
? $incomingBack
: '';
@endphp
@if($safeBack)
<div class="mt-10">
<a href="{!! $safeBack !!}" class="muted"> 返回上一页(保留上下文)</a>
</div>
@endif
</div>
<div class="card mb-20">
<h3>快捷筛选</h3>
<div class="muted mb-10">用于运营快速定位需要处理的套餐集合(口径基于筛选条件组合)。</div>
@php
// 快捷筛选仅保留“上下文”字段back/keyword避免把其它筛选条件叠加导致空结果
$buildQuickFilterUrl = function (array $overrides) {
$path = '/' . ltrim(request()->path(), '/');
$contextKeys = [
'back' => 1,
'keyword' => 1,
];
$q = array_intersect_key(request()->query(), $contextKeys);
foreach ($overrides as $k => $v) {
if ($v === null) {
unset($q[$k]);
continue;
}
$q[$k] = $v;
}
if (count($q) === 0) {
return $path;
}
return $path . '?' . \Illuminate\Support\Arr::query($q);
};
// “全部”:清空筛选,但保留 back用于返回来源页
$incomingBack = (string) request()->query('back', '');
$safeBack2 = (str_starts_with($incomingBack, '/')
&& !preg_match('/["\'<>]/', $incomingBack)
// back 本身不应再包含 back避免无限嵌套导致 URL 膨胀)
&& !preg_match('/(?:^|[?&])back=/', $incomingBack))
? $incomingBack
: '';
$allUrl = '/admin/plans';
if ($safeBack2 !== '') {
$allUrl .= '?' . \Illuminate\Support\Arr::query(['back' => $safeBack2]);
}
@endphp
<div>
<a href="{!! $allUrl !!}" class="muted">全部</a>
<span class="muted"></span>
<a href="{!! $buildQuickFilterUrl(['status' => 'active']) !!}" class="muted">启用中</a>
<span class="muted"></span>
<a href="{!! $buildQuickFilterUrl(['status' => 'inactive']) !!}" class="muted">停用</a>
<span class="muted"></span>
<a href="{!! $buildQuickFilterUrl(['published' => 'published']) !!}" class="muted">已发布</a>
<span class="muted"></span>
<a href="{!! $buildQuickFilterUrl(['published' => 'unpublished']) !!}" class="muted">未发布</a>
<span class="muted"></span>
<a href="{!! $buildQuickFilterUrl(['billing_cycle' => 'monthly']) !!}" class="muted">月付</a>
<span class="muted"></span>
<a href="{!! $buildQuickFilterUrl(['billing_cycle' => 'yearly']) !!}" class="muted">年付</a>
</div>
</div>
<div class="card mb-20">
<h3>工具</h3>
<div class="grid-2">
<form method="get" action="/admin/plans/export">
<input type="hidden" name="download" value="1">
<input type="hidden" name="status" value="{{ $filters['status'] ?? '' }}">
<input type="hidden" name="published" value="{{ $filters['published'] ?? '' }}">
<input type="hidden" name="billing_cycle" value="{{ $filters['billing_cycle'] ?? '' }}">
<input type="hidden" name="keyword" value="{{ $filters['keyword'] ?? '' }}">
<button type="submit">导出当前筛选结果CSV</button>
</form>
<form method="post" action="/admin/plans/seed-defaults" onsubmit="return confirm('仅在当前没有任何套餐时才会初始化。确认执行吗?');">
@csrf
<button type="submit" class="btn">一键初始化默认套餐(空库)</button>
<div class="muted muted-xs mt-6">安全护栏:当库中已存在套餐时会自动阻止,避免污染运营数据。</div>
</form>
</div>
</div>
<div class="card mb-20">
<h3>筛选条件</h3>
<form method="get" action="/admin/plans" class="grid-3">
<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="published">
<option value="">全部发布状态</option>
<option value="published" @selected(($filters['published'] ?? '') === 'published')>已发布</option>
<option value="unpublished" @selected(($filters['published'] ?? '') === 'unpublished')>未发布</option>
</select>
<select name="billing_cycle">
<option value="">全部计费周期</option>
@foreach(($filterOptions['billingCycles'] ?? []) as $value => $label)
<option value="{{ $value }}" @selected(($filters['billing_cycle'] ?? '') === $value)>{{ $label }}</option>
@endforeach
</select>
<input name="keyword" placeholder="搜索套餐名称 / 编码 / 描述" value="{{ $filters['keyword'] ?? '' }}">
<div>
<button type="submit">应用筛选</button>
</div>
</form>
</div>
<div class="grid-4 mb-20">
<div class="card">
<h3>套餐总数</h3>
<div class="num-md">{{ $summaryStats['total_plans'] ?? 0 }}</div>
</div>
<div class="card">
<h3>启用中套餐</h3>
<div class="num-md">{{ $summaryStats['active_plans'] ?? 0 }}</div>
</div>
<div class="card">
<h3>月付套餐</h3>
<div class="num-md">{{ $summaryStats['monthly_plans'] ?? 0 }}</div>
</div>
<div class="card">
<h3>年付套餐</h3>
<div class="num-md">{{ $summaryStats['yearly_plans'] ?? 0 }}</div>
</div>
<div class="card">
<h3>关联订阅总量</h3>
<div class="num-md">{{ $summaryStats['subscriptions_count'] ?? 0 }}</div>
</div>
<div class="card">
<h3>关联平台订单总量</h3>
<div class="num-md">{{ $summaryStats['platform_orders_count'] ?? 0 }}</div>
</div>
</div>
<div class="card">
<div class="flex-between">
<div>
<h3>套餐列表</h3>
<p class="muted muted-xs">后续将从这里进入套餐详情、授权项与订阅联动。</p>
</div>
@php
$createPlanUrl = '/admin/plans/create?' . \Illuminate\Support\Arr::query(['back' => $selfWithoutBack]);
@endphp
<a href="{!! $createPlanUrl !!}" class="btn">新建套餐</a>
</div>
<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>
</tr>
</thead>
<tbody>
@forelse($plans as $plan)
<tr>
<td>{{ $plan->id }}</td>
<td>
<strong>{{ $plan->name }}</strong>
<div class="muted muted-xs">{{ $plan->description ?: '暂无说明' }}</div>
</td>
<td>{{ $plan->code }}</td>
<td>{{ $billingCycleLabels[$plan->billing_cycle] ?? $plan->billing_cycle }}</td>
<td>¥{{ number_format((float) $plan->price, 2) }}</td>
<td>¥{{ number_format((float) $plan->list_price, 2) }}</td>
<td>{{ $statusLabels[$plan->status] ?? $plan->status }}</td>
<td>{{ $plan->sort }}</td>
<td>{{ optional($plan->published_at)->format('Y-m-d H:i:s') ?: '-' }}</td>
<td>
@php $subCount = (int) ($plan->subscriptions_count ?? 0); @endphp
@if($subCount > 0)
<a class="link" href="{!! $makeSubscriptionUrl(['plan_id' => $plan->id]) !!}">{{ $subCount }} </a>
@else
<span class="muted">0</span>
@endif
</td>
<td>
@php $orderCount = (int) ($plan->platform_orders_count ?? 0); @endphp
@if($orderCount > 0)
<a class="link" href="{!! $makePlatformOrderUrl(['plan_id' => $plan->id]) !!}">{{ $orderCount }} </a>
@else
<span class="muted">0</span>
@endif
</td>
<td>
@php
$editPlanUrl = '/admin/plans/' . $plan->id . '/edit?' . \Illuminate\Support\Arr::query(['back' => $selfWithoutBack]);
$createOrderUrl = '/admin/platform-orders/create?' . \Illuminate\Support\Arr::query([
'plan_id' => $plan->id,
'order_type' => 'new_purchase',
'back' => $selfWithoutBack,
]);
@endphp
<div class="actions gap-10">
<a href="{!! $editPlanUrl !!}" class="btn btn-secondary btn-sm">编辑</a>
<a href="{!! $createOrderUrl !!}" class="btn btn-sm">创建订单</a>
</div>
<form method="post" action="/admin/plans/{{ $plan->id }}/set-status" class="mt-6">
@csrf
<select name="status" onchange="this.form.submit()" class="w-140">
@foreach(($filterOptions['statuses'] ?? []) as $value => $label)
<option value="{{ $value }}" @selected($plan->status === $value)>{{ $label }}</option>
@endforeach
</select>
<noscript><button type="submit">更新状态</button></noscript>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="12" class="muted">暂无套餐数据,当前阶段先把套餐主表与总台目录立起来,后续可继续接套餐创建、授权项与订阅关联。</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="pagination-wrap">{{ $plans->links() }}</div>
@endsection