优化积分流水筛选与用户管理样式

This commit is contained in:
2026-04-26 17:54:24 +08:00
parent af772350c9
commit 61cfc2091c
11 changed files with 555 additions and 109 deletions
@@ -17,6 +17,9 @@ use App\Models\UserCurrencyLog;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* 类功能:提供后台全局金币/积分流水查询与多条件筛选。
*/
class CurrencyLogController extends Controller
{
/**
@@ -26,6 +29,12 @@ class CurrencyLogController extends Controller
public function index(Request $request): View
{
$query = UserCurrencyLog::query()->with('user');
$allSources = CurrencySource::cases();
$allowedSources = collect($allSources)->map(fn (CurrencySource $source) => $source->value)->all();
$selectedSources = collect($request->array('sources'))
->filter(fn (string $source) => in_array($source, $allowedSources, true))
->values()
->all();
// 查询条件过滤
if ($request->filled('username')) {
@@ -36,8 +45,8 @@ class CurrencyLogController extends Controller
$query->where('currency', $request->input('currency'));
}
if ($request->filled('source')) {
$query->where('source', $request->input('source'));
if ($selectedSources !== []) {
$query->whereIn('source', $selectedSources);
}
if ($request->filled('remark')) {
@@ -63,8 +72,6 @@ class CurrencyLogController extends Controller
// 默认按时间倒序
$logs = $query->latest('id')->paginate(50)->withQueryString();
$allSources = CurrencySource::cases();
return view('admin.currency-logs.index', compact('logs', 'allSources'));
return view('admin.currency-logs.index', compact('logs', 'allSources', 'selectedSources'));
}
}
@@ -14,11 +14,13 @@ namespace App\Http\Controllers\Admin;
use App\Enums\CurrencySource;
use App\Http\Controllers\Controller;
use App\Models\UserCurrencyLog;
use App\Services\UserCurrencyService;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* 类功能:展示后台积分流水统计与指定日期净流通数据。
*/
class CurrencyStatsController extends Controller
{
/**
@@ -45,20 +47,7 @@ class CurrencyStatsController extends Controller
);
// 今日净流通量(正向增加 - 负向消耗),可判断通货膨胀
$netFlow = [];
foreach (['exp', 'gold', 'charm'] as $currency) {
$totalIn = UserCurrencyLog::whereDate('created_at', $date)
->where('currency', $currency)->where('amount', '>', 0)
->sum('amount');
$totalOut = UserCurrencyLog::whereDate('created_at', $date)
->where('currency', $currency)->where('amount', '<', 0)
->sum('amount');
$netFlow[$currency] = [
'in' => $totalIn,
'out' => abs($totalOut),
'net' => $totalIn + $totalOut, // 净增量
];
}
$netFlow = $this->currencyService->netFlowStats($date);
// 所有已知来源(供视图展示缺失来源的空行)
$allSources = CurrencySource::cases();
+13 -2
View File
@@ -12,11 +12,15 @@
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\User;
use App\Services\UserCurrencyService;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* 类功能:展示全站排行榜、今日排行榜与用户个人积分流水记录。
*/
class LeaderboardController extends Controller
{
/**
@@ -133,8 +137,15 @@ class LeaderboardController extends Controller
$user = auth()->user();
$currency = request('currency');
$days = (int) request('days', 7);
$logs = $this->currencyService->userLogs($user->id, $currency ?: null, $days);
$direction = in_array(request('direction'), ['income', 'expense'], true) ? request('direction') : null;
$sourceOptions = CurrencySource::cases();
$allowedSources = collect($sourceOptions)->map(fn (CurrencySource $source) => $source->value)->all();
$selectedSources = collect(request()->array('sources'))
->filter(fn (string $source) => in_array($source, $allowedSources, true))
->values()
->all();
$logs = $this->currencyService->userLogs($user->id, $currency ?: null, $days, $direction, $selectedSources);
return view('leaderboard.my-logs', compact('logs', 'user', 'currency', 'days'));
return view('leaderboard.my-logs', compact('logs', 'user', 'currency', 'days', 'direction', 'sourceOptions', 'selectedSources'));
}
}
+76 -9
View File
@@ -16,7 +16,9 @@ namespace App\Services;
use App\Enums\CurrencySource;
use App\Models\User;
use App\Models\UserCurrencyLog;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
@@ -165,15 +167,75 @@ class UserCurrencyService
*/
public function activityStats(?string $date = null): Collection
{
$date = $date ?? today()->toDateString();
[$date, $rangeStart, $rangeEnd] = $this->statsDateBounds($date);
return UserCurrencyLog::query()
->whereDate('created_at', $date)
->selectRaw('source, currency, SUM(amount) as total_amount, COUNT(DISTINCT user_id) as participant_count')
->groupBy('source', 'currency')
->orderBy('currency')
->orderByRaw('ABS(SUM(amount)) DESC')
->get();
return Cache::remember("currency_stats:activity:{$date}", 300, function () use ($rangeStart, $rangeEnd) {
return UserCurrencyLog::query()
->where('created_at', '>=', $rangeStart)
->where('created_at', '<', $rangeEnd)
->selectRaw('source, currency, SUM(amount) as total_amount, COUNT(DISTINCT user_id) as participant_count')
->groupBy('source', 'currency')
->orderBy('currency')
->orderByRaw('ABS(SUM(amount)) DESC')
->get();
});
}
/**
* 查询某日三种货币的净流通量(流入、流出、净增)。
*
* @param string|null $date 日期字符串如 '2026-02-28',默认今日
* @return array<string, array{in:int, out:int, net:int}>
*/
public function netFlowStats(?string $date = null): array
{
[$date, $rangeStart, $rangeEnd] = $this->statsDateBounds($date);
$rows = Cache::remember("currency_stats:net_flow:{$date}", 300, function () use ($rangeStart, $rangeEnd) {
return UserCurrencyLog::query()
->where('created_at', '>=', $rangeStart)
->where('created_at', '<', $rangeEnd)
->selectRaw('
currency,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) as total_in,
ABS(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END)) as total_out,
SUM(amount) as net_total
')
->groupBy('currency')
->get()
->keyBy('currency');
});
$netFlow = [];
foreach (['exp', 'gold', 'charm'] as $currency) {
$row = $rows->get($currency);
$netFlow[$currency] = [
'in' => (int) ($row->total_in ?? 0),
'out' => (int) ($row->total_out ?? 0),
'net' => (int) ($row->net_total ?? 0),
];
}
return $netFlow;
}
/**
* 解析统计查询的日期边界,统一复用缓存 key 与时间范围。
*
* @param string|null $date 日期字符串如 '2026-02-28'
* @return array{0:string, 1:CarbonImmutable, 2:CarbonImmutable}
*/
private function statsDateBounds(?string $date = null): array
{
$statsDate = CarbonImmutable::parse($date ?? today()->toDateString())->startOfDay();
return [
$statsDate->toDateString(),
$statsDate,
$statsDate->addDay(),
];
}
/**
@@ -220,12 +282,17 @@ class UserCurrencyService
* @param int $userId 用户 ID
* @param string|null $currency null 时返回所有货币类型
* @param int $days 查询最近多少天
* @param string|null $direction income=收入 / expense=支出 / null=全部
* @param array<int, string> $sources 来源 source 值列表,为空时不过滤
*/
public function userLogs(int $userId, ?string $currency = null, int $days = 7): Collection
public function userLogs(int $userId, ?string $currency = null, int $days = 7, ?string $direction = null, array $sources = []): Collection
{
return UserCurrencyLog::query()
->where('user_id', $userId)
->when($currency, fn ($q) => $q->where('currency', $currency))
->when($direction === 'income', fn ($q) => $q->where('amount', '>', 0))
->when($direction === 'expense', fn ($q) => $q->where('amount', '<', 0))
->when($sources !== [], fn ($q) => $q->whereIn('source', $sources))
->where('created_at', '>=', now()->subDays($days))
->orderByDesc('created_at')
->limit(200)
@@ -3,6 +3,10 @@
@section('title', '金币/积分流水查询')
@section('content')
@php
$selectedSourceCount = count($selectedSources ?? []);
@endphp
<!-- 筛选面板 -->
<div class="bg-white p-5 rounded-xl border border-gray-100 shadow-sm mb-6">
<form action="{{ route('admin.currency-logs.index') }}" method="GET" class="flex flex-wrap items-end gap-4">
@@ -39,17 +43,37 @@
</div>
<!-- 来源途径 -->
<div class="flex-1 min-w-[140px]">
<label for="source" class="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">来源途径</label>
<select name="source" id="source"
class="w-full border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2">
<option value="">全部来源</option>
@foreach ($allSources as $src)
<option value="{{ $src->value }}" {{ request('source') == $src->value ? 'selected' : '' }}>
{{ $src->label() }}
</option>
@endforeach
</select>
<div class="flex-1 min-w-[180px]">
<label class="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">来源途径</label>
<details class="relative" data-admin-source-filter>
<summary
class="flex w-full cursor-pointer list-none items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-indigo-400">
<span class="truncate">{{ $selectedSourceCount > 0 ? '已选 '.$selectedSourceCount.' 项' : '全部来源' }}</span>
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</summary>
<div class="absolute left-0 z-20 mt-2 flex w-full min-w-[220px] max-w-sm flex-col rounded-lg border border-gray-200 bg-white shadow-lg">
<div class="grid max-h-64 gap-1 overflow-y-auto p-3">
@foreach ($allSources as $src)
<label class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
<input type="checkbox" name="sources[]" value="{{ $src->value }}"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
@checked(in_array($src->value, $selectedSources ?? [], true))>
<span class="truncate" title="{{ $src->label() }}">{{ $src->label() }}</span>
</label>
@endforeach
</div>
<div class="sticky bottom-0 flex items-center justify-between border-t border-gray-100 bg-white px-3 py-3">
<a href="{{ route('admin.currency-logs.index', request()->except('sources')) }}"
class="text-xs text-gray-400 hover:text-gray-600">清空来源</a>
<span class="text-xs text-gray-400">勾选后点查询生效</span>
</div>
</div>
</details>
</div>
<!-- 备注关键词 -->
@@ -176,4 +200,26 @@
</div>
@endif
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const sourceFilter = document.querySelector('[data-admin-source-filter]');
if (!sourceFilter) {
return;
}
document.addEventListener('click', function (event) {
if (!sourceFilter.open) {
return;
}
if (sourceFilter.contains(event.target)) {
return;
}
sourceFilter.removeAttribute('open');
});
});
</script>
@endsection
+76 -62
View File
@@ -3,16 +3,31 @@
@section('title', '用户检索与管理')
@section('content')
@php
$filterLabelClass = 'block mb-1 text-xs font-semibold uppercase tracking-wider text-gray-500';
$filterInputClass = 'px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm';
$tableHeadClass = 'p-4 text-xs font-semibold uppercase tracking-wider text-gray-500';
$primaryTextClass = 'text-sm font-semibold text-gray-800';
$secondaryTextClass = 'text-xs text-gray-400';
$numericTextClass = 'text-sm font-mono';
$statusBadgeClass = 'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-semibold border';
$actionButtonClass = 'rounded-lg px-3 py-1.5 text-xs font-semibold transition cursor-pointer';
$modalLabelClass = 'mb-1 block text-xs font-semibold uppercase tracking-wider text-gray-500';
$modalInputClass = 'w-full rounded-md border border-gray-300 p-2 text-sm text-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500';
@endphp
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden mb-6" x-data="userEditor()">
<div class="p-6 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
<form action="{{ route('admin.users.index') }}" method="GET" class="flex gap-2">
<input type="text" name="username" value="{{ request('username') }}" placeholder="搜索用户名..."
class="px-3 py-1.5 border border-gray-300 rounded shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
<div class="p-6 border-b border-gray-100 bg-gray-50">
<form action="{{ route('admin.users.index') }}" method="GET" class="flex flex-wrap items-end gap-3">
<div>
<label class="{{ $filterLabelClass }}">用户名</label>
<input type="text" name="username" value="{{ request('username') }}" placeholder="搜索用户名..."
class="w-64 {{ $filterInputClass }}">
</div>
<button type="submit"
class="bg-indigo-600 text-white px-4 py-1.5 rounded hover:bg-indigo-700 font-bold shadow-sm transition">搜索</button>
class="bg-indigo-600 text-white px-4 py-2 rounded-lg font-semibold shadow-sm transition hover:bg-indigo-700">搜索</button>
<a href="{{ route('admin.users.index') }}"
class="px-4 py-1.5 bg-white border border-gray-300 rounded text-gray-700 hover:bg-gray-50">重置</a>
class="px-4 py-2 bg-white border border-gray-300 rounded-lg text-sm font-semibold text-gray-700 hover:bg-gray-50 transition">重置</a>
</form>
</div>
@@ -36,59 +51,58 @@
@endphp
<table class="w-full text-left border-collapse whitespace-nowrap">
<thead>
<tr
class="bg-gray-50 border-b border-gray-100 text-xs text-gray-500 uppercase font-bold tracking-wider">
<th class="p-4">
<a href="{{ $sortLink('id') }}" class="hover:text-indigo-600 flex items-center gap-1">
<tr class="bg-gray-50 border-b border-gray-100">
<th class="{{ $tableHeadClass }}">
<a href="{{ $sortLink('id') }}" class="flex items-center gap-1 hover:text-indigo-600">
ID<span class="text-indigo-500">{{ $arrow('id') }}</span>
</a>
</th>
<th class="p-4">注册名</th>
<th class="p-4">性别</th>
<th class="p-4">
<a href="{{ $sortLink('user_level') }}" class="hover:text-indigo-600 flex items-center gap-1">
<th class="{{ $tableHeadClass }}">注册名</th>
<th class="{{ $tableHeadClass }}">性别</th>
<th class="{{ $tableHeadClass }}">
<a href="{{ $sortLink('user_level') }}" class="flex items-center gap-1 hover:text-indigo-600">
等级<span class="text-indigo-500">{{ $arrow('user_level') }}</span>
</a>
</th>
<th class="p-4">职务</th>
<th class="p-4">
<a href="{{ $sortLink('exp_num') }}" class="hover:text-indigo-600 flex items-center gap-1">
<th class="{{ $tableHeadClass }}">职务</th>
<th class="{{ $tableHeadClass }}">
<a href="{{ $sortLink('exp_num') }}" class="flex items-center gap-1 hover:text-indigo-600">
经验<span class="text-indigo-500">{{ $arrow('exp_num') }}</span>
</a>
</th>
<th class="p-4">
<a href="{{ $sortLink('jjb') }}" class="hover:text-yellow-600 flex items-center gap-1">
<th class="{{ $tableHeadClass }}">
<a href="{{ $sortLink('jjb') }}" class="flex items-center gap-1 hover:text-yellow-600">
金币<span class="text-yellow-500">{{ $arrow('jjb') }}</span>
</a>
</th>
<th class="p-4">
<a href="{{ $sortLink('meili') }}" class="hover:text-pink-600 flex items-center gap-1">
<th class="{{ $tableHeadClass }}">
<a href="{{ $sortLink('meili') }}" class="flex items-center gap-1 hover:text-pink-600">
魅力<span class="text-pink-500">{{ $arrow('meili') }}</span>
</a>
</th>
<th class="p-4">注册时间</th>
<th class="p-4">
<a href="{{ $sortLink('wxid') }}" class="hover:text-green-600 flex items-center gap-1">
<th class="{{ $tableHeadClass }}">注册时间</th>
<th class="{{ $tableHeadClass }}">
<a href="{{ $sortLink('wxid') }}" class="flex items-center gap-1 hover:text-green-600">
微信绑定<span class="text-green-500">{{ $arrow('wxid') }}</span>
</a>
</th>
<th class="p-4">
<a href="{{ $sortLink('online') }}" class="hover:text-green-600 flex items-center gap-1">
<th class="{{ $tableHeadClass }}">
<a href="{{ $sortLink('online') }}" class="flex items-center gap-1 hover:text-green-600">
在线<span class="text-green-500">{{ $arrow('online') }}</span>
</a>
</th>
<th class="p-4 text-right">管理操作</th>
<th class="{{ $tableHeadClass }} text-right">管理操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($users as $user)
<tr class="hover:bg-gray-50 transition">
<td class="p-4 font-mono text-xs text-gray-500">{{ $user->id }}</td>
<td class="p-4 text-xs font-mono text-gray-500">{{ $user->id }}</td>
<td class="p-4">
<div class="flex items-center space-x-3">
<img src="{{ $user->headface_url ?? '/images/headface/1.gif' }}"
class="w-8 h-8 rounded border object-cover">
<span class="font-bold text-gray-800">{{ $user->username }}</span>
<span class="{{ $primaryTextClass }}">{{ $user->username }}</span>
@if ($user->isVip())
<span title="{{ $user->vipName() }}"
style="color: {{ $user->vipLevel?->color ?? '#f59e0b' }}">{{ $user->vipIcon() }}</span>
@@ -105,32 +119,32 @@
<td class="p-4">
@if ($user->activePosition)
@php $pos = $user->activePosition->position; @endphp
<div class="text-xs text-gray-400">{{ $pos->department->name }}</div>
<div class="font-bold text-sm" style="color: {{ $pos->department->color }}">
<div class="{{ $secondaryTextClass }}">{{ $pos->department->name }}</div>
<div class="font-semibold text-sm" style="color: {{ $pos->department->color }}">
{{ $pos->icon }} {{ $pos->name }}
</div>
@else
<span class="text-gray-300 text-xs"></span>
@endif
</td>
<td class="p-4 text-sm font-mono text-gray-600">
<td class="p-4 {{ $numericTextClass }} text-gray-600">
{{ number_format($user->exp_num ?? 0) }}
</td>
<td class="p-4 text-sm font-mono text-yellow-700">
<td class="p-4 {{ $numericTextClass }} text-yellow-700">
{{ number_format($user->jjb ?? 0) }}
</td>
<td class="p-4 text-sm font-mono text-pink-600">
<td class="p-4 {{ $numericTextClass }} text-pink-600">
{{ number_format($user->meili ?? 0) }}
</td>
<td class="p-4 text-sm font-mono text-gray-500">{{ $user->created_at->format('y-m-d') }}
<td class="p-4 {{ $numericTextClass }} text-gray-500">{{ $user->created_at->format('y-m-d') }}
</td>
<td class="p-4">
@if(!empty($user->wxid))
<span class="inline-flex items-center gap-1.5 text-xs font-bold px-2 py-0.5 rounded-full bg-green-50 text-green-700 border border-green-200">
<span class="{{ $statusBadgeClass }} bg-green-50 text-green-700 border-green-200">
已绑定
</span>
@else
<span class="inline-flex items-center gap-1.5 text-xs font-bold px-2 py-0.5 rounded-full bg-gray-50 text-gray-400 border border-gray-200">
<span class="{{ $statusBadgeClass }} bg-gray-50 text-gray-400 border-gray-200">
未绑定
</span>
@endif
@@ -138,8 +152,8 @@
<td class="p-4">
@php $isOnline = $onlineUsernames->contains($user->username); @endphp
<span
class="inline-flex items-center gap-1.5 text-xs font-bold px-2 py-0.5 rounded-full
{{ $isOnline ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400' }}">
class="{{ $statusBadgeClass }}
{{ $isOnline ? 'bg-green-100 text-green-700 border-green-200' : 'bg-gray-100 text-gray-400 border-gray-200' }}">
<span
class="w-1.5 h-1.5 rounded-full {{ $isOnline ? 'bg-green-500' : 'bg-gray-300' }}"></span>
{{ $isOnline ? '在线' : '离线' }}
@@ -187,7 +201,7 @@
<!-- 分页链接 -->
@if ($users->hasPages())
<div class="p-4 border-t border-gray-100">
<div class="p-4 border-t border-gray-100 text-sm text-gray-600">
{{ $users->links() }}
</div>
@endif
@@ -199,7 +213,7 @@
class="bg-white rounded-xl shadow-2xl w-full max-w-lg transform transition-all" x-transition>
<div
class="bg-indigo-900 border-b border-indigo-800 px-6 py-4 flex justify-between items-center rounded-t-xl text-white">
<h3 class="font-bold text-lg">编辑用户:<span x-text="editingUser.username" class="text-indigo-300"></span>
<h3 class="text-sm font-semibold uppercase tracking-wider">编辑用户:<span x-text="editingUser.username" class="text-indigo-300"></span>
</h3>
<button @click="showEditModal = false" class="text-gray-400 hover:text-white">&times;</button>
</div>
@@ -209,7 +223,7 @@
<div x-show="editToast" x-transition style="display:none;"
:class="editToastOk ? 'bg-green-50 border-green-400 text-green-800' :
'bg-red-50 border-red-400 text-red-800'"
class="mb-4 px-4 py-2 border-l-4 rounded text-sm font-bold" x-text="editToastMsg">
class="mb-4 rounded border-l-4 px-4 py-2 text-sm font-semibold" x-text="editToastMsg">
</div>
<form @submit.prevent="submitEditUser($el)" method="POST">
@@ -218,28 +232,28 @@
<div class="grid grid-cols-2 gap-4">
{{-- 经验 --}}
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">经验值</label>
<label class="{{ $modalLabelClass }}">经验值</label>
<input type="number" name="exp_num" x-model="editingUser.exp_num" required
min="0"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border text-sm">
class="{{ $modalInputClass }}">
</div>
{{-- 金币 --}}
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">金币 (jjb)</label>
<label class="{{ $modalLabelClass }}">金币 (jjb)</label>
<input type="number" name="jjb" x-model="editingUser.jjb" required min="0"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border text-sm">
class="{{ $modalInputClass }}">
</div>
{{-- 魅力 --}}
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">魅力值</label>
<label class="{{ $modalLabelClass }}">魅力值</label>
<input type="number" name="meili" x-model="editingUser.meili" required min="0"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border text-sm">
class="{{ $modalInputClass }}">
</div>
{{-- 性别 --}}
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">性别</label>
<label class="{{ $modalLabelClass }}">性别</label>
<select name="sex" x-model="editingUser.sex"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border text-sm">
class="{{ $modalInputClass }}">
<option value="1"></option>
<option value="2"></option>
<option value="0">保密</option>
@@ -247,7 +261,7 @@
</div>
{{-- 访问次数 --}}
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">访问次数</label>
<label class="{{ $modalLabelClass }}">访问次数</label>
<input type="text" disabled :value="editingUser.visit_num"
class="w-full bg-gray-100 border-gray-200 rounded-md p-2 border text-sm text-gray-500">
</div>
@@ -255,17 +269,17 @@
{{-- 签名 --}}
<div class="mt-4">
<label class="block text-xs font-bold text-gray-600 mb-1">个性签名</label>
<label class="{{ $modalLabelClass }}">个性签名</label>
<input type="text" name="qianming" x-model="editingUser.qianming" maxlength="255"
placeholder="暂无签名"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border text-sm">
class="{{ $modalInputClass }}">
</div>
{{-- 任命职务 --}}
<div class="mt-4">
<label class="block text-xs font-bold text-gray-600 mb-1">职务</label>
<label class="{{ $modalLabelClass }}">职务</label>
<select name="position_id" x-model="editingUser.position_id"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border text-sm">
class="{{ $modalInputClass }}">
<option value="">无职务</option>
@foreach ($departments as $department)
<optgroup label="{{ $department->name }}">
@@ -280,9 +294,9 @@
{{-- VIP 会员设置 --}}
<div class="mt-4 grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">VIP 会员等级</label>
<label class="{{ $modalLabelClass }}">VIP 会员等级</label>
<select name="vip_level_id" x-model="editingUser.vip_level_id"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border text-sm">
class="{{ $modalInputClass }}">
<option value="">无(普通用户)</option>
@foreach ($vipLevels as $vl)
<option value="{{ $vl->id }}">{{ $vl->icon }}
@@ -292,18 +306,18 @@
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">会员到期时间
<span class="font-normal text-gray-400">(留空=永久)</span></label>
<label class="{{ $modalLabelClass }}">会员到期时间
<span class="normal-case font-normal tracking-normal text-gray-400">(留空=永久)</span></label>
<input type="date" name="hy_time" x-model="editingUser.hy_time"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border text-sm">
class="{{ $modalInputClass }}">
</div>
</div>
{{-- 密码 --}}
<div class="mt-4">
<label
class="block text-xs font-bold pl-2 text-red-600 border-l-4 border-red-500 bg-red-50 p-2 mb-1">强制重置密码
<span class="font-normal text-gray-500">(留空不修改)</span></label>
class="mb-1 block border-l-4 border-red-500 bg-red-50 p-2 pl-2 text-xs font-semibold uppercase tracking-wider text-red-600">强制重置密码
<span class="normal-case font-normal tracking-normal text-gray-500">(留空不修改)</span></label>
<input type="text" name="password" placeholder="输入新密码"
class="w-full border-red-300 rounded-md shadow-sm focus:border-red-500 focus:ring-red-500 p-2 border text-sm placeholder-red-300">
</div>
+52 -2
View File
@@ -1,6 +1,6 @@
{{--
文件功能:用户个人积分流水日志页面
用户可筛选查看自己的经验、金币、魅力变动历史,按日期倒序排列
用户可筛选查看自己的经验、金币、魅力收入和支出变动历史,按日期倒序排列
@extends layouts.app
--}}
@@ -13,11 +13,25 @@
@section('content')
<main class="p-4 sm:p-6 lg:p-8">
<div class="max-w-4xl mx-auto">
@php
$selectedSourceCount = count($selectedSources ?? []);
@endphp
{{-- 筛选栏 --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6 flex flex-wrap gap-3 items-center">
<span class="text-gray-600 text-sm font-medium">筛选:</span>
{{-- 收支方向 --}}
<div class="flex gap-2">
@foreach(['' => '全部', 'income' => '收入', 'expense' => '支出'] as $val => $label)
<a href="{{ route('currency.my-logs', array_merge(request()->query(), ['direction' => $val, 'days' => $days])) }}"
class="px-3 py-1 rounded-full text-xs font-medium transition
{{ ($direction ?? '') === $val ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-green-100' }}">
{{ $label }}
</a>
@endforeach
</div>
{{-- 货币类型 --}}
<div class="flex gap-2">
@foreach(['' => '全部', 'exp' => '⚡ 经验', 'gold' => '💰 金币', 'charm' => '🌸 魅力'] as $val => $label)
@@ -29,6 +43,41 @@
@endforeach
</div>
{{-- 来源筛选 --}}
<form method="GET" action="{{ route('currency.my-logs') }}" class="relative">
<input type="hidden" name="direction" value="{{ $direction ?? '' }}">
<input type="hidden" name="currency" value="{{ $currency ?? '' }}">
<input type="hidden" name="days" value="{{ $days }}">
<details class="relative">
<summary class="list-none cursor-pointer select-none px-3 py-1 rounded-full text-xs font-medium transition bg-gray-100 text-gray-600 hover:bg-indigo-100">
来源:{{ $selectedSourceCount > 0 ? '已选 '.$selectedSourceCount.' 项' : '全部' }}
</summary>
<div class="absolute left-0 z-20 mt-2 flex w-72 max-h-80 flex-col rounded-lg border border-gray-200 bg-white shadow-lg">
<div class="grid max-h-60 gap-1 overflow-y-auto p-3">
@foreach($sourceOptions as $sourceOption)
<label class="flex items-center gap-2 rounded-md px-2 py-1.5 text-xs text-gray-600 hover:bg-gray-50">
<input type="checkbox" name="sources[]" value="{{ $sourceOption->value }}"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
@checked(in_array($sourceOption->value, $selectedSources ?? [], true))>
<span class="truncate" title="{{ $sourceOption->label() }}">{{ $sourceOption->label() }}</span>
</label>
@endforeach
</div>
<div class="sticky bottom-0 flex items-center justify-between border-t border-gray-100 bg-white px-3 py-3">
<a href="{{ route('currency.my-logs', request()->except('sources')) }}"
class="text-xs text-gray-400 hover:text-gray-600">清空来源</a>
<button type="submit"
class="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700">
应用
</button>
</div>
</div>
</details>
</form>
{{-- 日期范围 --}}
<div class="flex gap-2 ml-auto">
@foreach([7 => '7 天', 14 => '14 天', 30 => '30 天'] as $d => $label)
@@ -87,7 +136,8 @@
<td class="px-4 py-3 text-right text-gray-500">
{{ number_format($log->balance_after) }}
</td>
<td class="px-4 py-3 text-gray-400 text-xs max-w-[160px] truncate">
<td class="px-4 py-3 text-gray-400 text-xs max-w-[160px] truncate"
title="{{ $log->remark ?: '—' }}">
{{ $log->remark ?: '—' }}
</td>
</tr>
@@ -0,0 +1,64 @@
<?php
/**
* 文件功能:后台金币/积分流水查询测试
*
* 覆盖后台流水页对来源多选筛选的查询行为。
*/
namespace Tests\Feature\Feature;
use App\Enums\CurrencySource;
use App\Models\Sysparam;
use App\Models\User;
use App\Models\UserCurrencyLog;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 类功能:验证后台金币/积分流水查询页筛选行为。
*/
class AdminCurrencyLogControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 验证后台流水页支持按多个来源同时筛选。
*/
public function test_admin_can_filter_currency_logs_by_multiple_sources(): void
{
Sysparam::updateOrCreate(['alias' => 'superlevel'], ['body' => '100']);
$admin = User::factory()->create(['user_level' => 100]);
foreach ([CurrencySource::AUTO_SAVE, CurrencySource::SIGN_IN, CurrencySource::ADMIN_ADJUST] as $index => $source) {
UserCurrencyLog::query()->create([
'user_id' => $admin->id,
'username' => $admin->username,
'currency' => 'gold',
'amount' => 10 + $index,
'balance_after' => 100 + $index,
'source' => $source->value,
'remark' => $source->label(),
'created_at' => now()->subMinutes($index),
]);
}
$response = $this->actingAs($admin)->get(route('admin.currency-logs.index', [
'sources' => [
CurrencySource::AUTO_SAVE->value,
CurrencySource::SIGN_IN->value,
],
]));
$response->assertOk();
$response->assertViewIs('admin.currency-logs.index');
$response->assertViewHas('selectedSources', [
CurrencySource::AUTO_SAVE->value,
CurrencySource::SIGN_IN->value,
]);
$this->assertSame(
[CurrencySource::AUTO_SAVE->value, CurrencySource::SIGN_IN->value],
$response->viewData('logs')->getCollection()->pluck('source')->sort()->values()->all()
);
}
}
@@ -0,0 +1,86 @@
<?php
/**
* 文件功能:后台积分流水统计页面测试
*
* 覆盖后台统计页的来源聚合与净流通量口径。
*/
namespace Tests\Feature\Feature;
use App\Enums\CurrencySource;
use App\Models\Sysparam;
use App\Models\User;
use App\Models\UserCurrencyLog;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
* 类功能:验证后台积分流水统计页能正确汇总指定日期数据。
*/
class AdminCurrencyStatsControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 验证后台积分流水统计页会返回指定日期的来源统计与净流通数据。
*/
public function test_admin_can_view_currency_stats_summary_for_selected_date(): void
{
Cache::flush();
Sysparam::updateOrCreate(['alias' => 'superlevel'], ['body' => '100']);
$admin = User::factory()->create(['user_level' => 100]);
$date = '2026-04-26';
UserCurrencyLog::query()->create([
'user_id' => $admin->id,
'username' => $admin->username,
'currency' => 'gold',
'amount' => 120,
'balance_after' => 120,
'source' => CurrencySource::SIGN_IN->value,
'remark' => '签到奖励',
'created_at' => "{$date} 10:00:00",
]);
UserCurrencyLog::query()->create([
'user_id' => $admin->id,
'username' => $admin->username,
'currency' => 'gold',
'amount' => -20,
'balance_after' => 100,
'source' => CurrencySource::FISHING_COST->value,
'remark' => '钓鱼消耗',
'created_at' => "{$date} 11:00:00",
]);
UserCurrencyLog::query()->create([
'user_id' => $admin->id,
'username' => $admin->username,
'currency' => 'exp',
'amount' => 80,
'balance_after' => 80,
'source' => CurrencySource::AUTO_SAVE->value,
'remark' => '自动存点',
'created_at' => "{$date} 12:00:00",
]);
$response = $this->actingAs($admin)->get(route('admin.currency-stats.index', ['date' => $date]));
$response->assertOk();
$response->assertViewIs('admin.currency-stats.index');
$response->assertViewHas('date', $date);
$response->assertViewHas('netFlow', [
'exp' => ['in' => 80, 'out' => 0, 'net' => 80],
'gold' => ['in' => 120, 'out' => 20, 'net' => 100],
'charm' => ['in' => 0, 'out' => 0, 'net' => 0],
]);
$statsByType = $response->viewData('statsByType');
$this->assertSame(120, (int) $statsByType['gold'][CurrencySource::SIGN_IN->value]->total_amount);
$this->assertSame(80, (int) $statsByType['exp'][CurrencySource::AUTO_SAVE->value]->total_amount);
}
}
@@ -23,6 +23,24 @@ class AdminUserManagerTest extends TestCase
{
use RefreshDatabase;
/**
* 方法功能:验证后台用户管理页可以正常打开并展示核心文案。
*/
public function test_site_owner_can_view_user_manager_page(): void
{
$siteOwner = $this->createSiteOwner();
User::factory()->create([
'username' => 'viewer-target',
]);
$response = $this->actingAs($siteOwner)->get(route('admin.users.index'));
$response->assertOk();
$response->assertSee('用户检索与管理');
$response->assertSee('注册名');
$response->assertSee('管理操作');
}
/**
* 方法功能:验证站长可以在用户编辑页直接任命职务。
*/
+97 -3
View File
@@ -2,16 +2,24 @@
namespace Tests\Feature;
use App\Enums\CurrencySource;
use App\Models\Sysparam;
use App\Models\User;
use App\Models\UserCurrencyLog;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 功能说明:验证排行榜与个人积分流水页面的访问和筛选行为。
*/
class LeaderboardControllerTest extends TestCase
{
use RefreshDatabase;
public function test_can_view_leaderboard_index()
/**
* 验证用户可以访问排行榜首页并看到过滤后的榜单数据。
*/
public function test_can_view_leaderboard_index(): void
{
$user = User::factory()->create(['exp_num' => 10, 'jjb' => 100, 'meili' => 5]);
@@ -51,7 +59,10 @@ class LeaderboardControllerTest extends TestCase
$this->assertEquals(50, $topCharm->first()->meili);
}
public function test_can_view_today_leaderboard()
/**
* 验证用户可以访问今日排行榜页面。
*/
public function test_can_view_today_leaderboard(): void
{
$user = User::factory()->create();
@@ -61,7 +72,10 @@ class LeaderboardControllerTest extends TestCase
$response->assertViewIs('leaderboard.today');
}
public function test_can_view_my_currency_logs()
/**
* 验证用户可以访问自己的积分流水页面。
*/
public function test_can_view_my_currency_logs(): void
{
$user = User::factory()->create();
@@ -70,4 +84,84 @@ class LeaderboardControllerTest extends TestCase
$response->assertStatus(200);
$response->assertViewIs('leaderboard.my-logs');
}
/**
* 验证个人积分流水可以按收入和支出 tab 分开筛选。
*/
public function test_can_filter_my_currency_logs_by_direction(): void
{
$user = User::factory()->create();
UserCurrencyLog::query()->create([
'user_id' => $user->id,
'username' => $user->username,
'currency' => 'gold',
'amount' => 100,
'balance_after' => 100,
'source' => 'admin_adjust',
'remark' => '测试收入',
'created_at' => now(),
]);
UserCurrencyLog::query()->create([
'user_id' => $user->id,
'username' => $user->username,
'currency' => 'gold',
'amount' => -30,
'balance_after' => 70,
'source' => 'admin_adjust',
'remark' => '测试支出',
'created_at' => now(),
]);
$incomeResponse = $this->actingAs($user)->get(route('currency.my-logs', ['direction' => 'income']));
$incomeResponse->assertStatus(200);
$this->assertSame([100], $incomeResponse->viewData('logs')->pluck('amount')->all());
$this->assertSame('income', $incomeResponse->viewData('direction'));
$expenseResponse = $this->actingAs($user)->get(route('currency.my-logs', ['direction' => 'expense']));
$expenseResponse->assertStatus(200);
$this->assertSame([-30], $expenseResponse->viewData('logs')->pluck('amount')->all());
$this->assertSame('expense', $expenseResponse->viewData('direction'));
}
/**
* 验证个人积分流水可以按多个来源叠加筛选。
*/
public function test_can_filter_my_currency_logs_by_multiple_sources(): void
{
$user = User::factory()->create();
foreach ([CurrencySource::AUTO_SAVE, CurrencySource::SIGN_IN, CurrencySource::ADMIN_ADJUST] as $index => $source) {
UserCurrencyLog::query()->create([
'user_id' => $user->id,
'username' => $user->username,
'currency' => 'gold',
'amount' => 10 + $index,
'balance_after' => 100 + $index,
'source' => $source->value,
'remark' => $source->label(),
'created_at' => now()->subMinutes($index),
]);
}
$response = $this->actingAs($user)->get(route('currency.my-logs', [
'sources' => [
CurrencySource::AUTO_SAVE->value,
CurrencySource::SIGN_IN->value,
],
]));
$response->assertStatus(200);
$this->assertSame(
[CurrencySource::AUTO_SAVE->value, CurrencySource::SIGN_IN->value],
$response->viewData('logs')->pluck('source')->sort()->values()->all()
);
$this->assertSame(
[CurrencySource::AUTO_SAVE->value, CurrencySource::SIGN_IN->value],
$response->viewData('selectedSources')
);
}
}