增加每日推送的导出和排序
This commit is contained in:
205
app/common/library/MallDailyPushExport.php
Normal file
205
app/common/library/MallDailyPushExport.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library;
|
||||
|
||||
use app\common\model\MallDailyPush;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 每日推送数据导出(流式 CSV,兼容 Excel 打开)
|
||||
*/
|
||||
class MallDailyPushExport
|
||||
{
|
||||
public const MAX_EXPORT_LIMIT = 100000;
|
||||
|
||||
public const CHUNK_SIZE = 2000;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $fieldLabels = [
|
||||
'id' => 'ID',
|
||||
'user_id' => 'playX-ID',
|
||||
'date' => '业务日期',
|
||||
'username' => '用户名',
|
||||
'yesterday_win_loss_net' => '昨日净输赢',
|
||||
'yesterday_total_deposit' => '昨日总充值',
|
||||
'lifetime_total_deposit' => '历史总充值',
|
||||
'lifetime_total_withdraw' => '历史总提现',
|
||||
'create_time' => '创建时间',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $fieldLabelsEn = [
|
||||
'id' => 'ID',
|
||||
'user_id' => 'PlayX user ID',
|
||||
'date' => 'Business date',
|
||||
'username' => 'Username',
|
||||
'yesterday_win_loss_net' => 'Yesterday net win/loss',
|
||||
'yesterday_total_deposit' => 'Yesterday total deposit',
|
||||
'lifetime_total_deposit' => 'Lifetime total deposit',
|
||||
'lifetime_total_withdraw' => 'Lifetime total withdraw',
|
||||
'create_time' => 'Created at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<int, string> $fields
|
||||
*/
|
||||
public function export(
|
||||
MallDailyPush $model,
|
||||
array $where,
|
||||
array $alias,
|
||||
array $order,
|
||||
array $fields,
|
||||
int $limit,
|
||||
string $lang = 'zh-cn'
|
||||
): Response {
|
||||
$fields = $this->normalizeFields($fields);
|
||||
if ($fields === []) {
|
||||
throw new \InvalidArgumentException(__('Parameter error'));
|
||||
}
|
||||
|
||||
$limit = max(1, min(self::MAX_EXPORT_LIMIT, $limit));
|
||||
|
||||
$exportDir = runtime_path('export');
|
||||
if (!is_dir($exportDir) && !mkdir($exportDir, 0755, true) && !is_dir($exportDir)) {
|
||||
throw new \RuntimeException('Failed to create export directory');
|
||||
}
|
||||
|
||||
$filepath = $exportDir . DIRECTORY_SEPARATOR . 'daily_push_' . date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.csv';
|
||||
$labels = $this->resolveFieldLabels($lang);
|
||||
$handle = fopen($filepath, 'wb');
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException('Failed to create export file');
|
||||
}
|
||||
|
||||
try {
|
||||
fwrite($handle, "\xEF\xBB\xBF");
|
||||
fputcsv($handle, $this->buildHeaderRow($fields, $labels));
|
||||
|
||||
$exported = 0;
|
||||
$page = 1;
|
||||
while ($exported < $limit) {
|
||||
$batchSize = min(self::CHUNK_SIZE, $limit - $exported);
|
||||
$rows = $model
|
||||
->field($fields)
|
||||
->alias($alias)
|
||||
->where($where)
|
||||
->order($order)
|
||||
->page($page, $batchSize)
|
||||
->select();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($handle, $this->formatRow($row->toArray(), $fields));
|
||||
$exported++;
|
||||
if ($exported >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows->count() < $batchSize) {
|
||||
break;
|
||||
}
|
||||
$page++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
fclose($handle);
|
||||
if (is_file($filepath)) {
|
||||
@unlink($filepath);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
register_shutdown_function(static function () use ($filepath): void {
|
||||
if (is_file($filepath)) {
|
||||
@unlink($filepath);
|
||||
}
|
||||
});
|
||||
|
||||
$filename = 'daily_push_' . date('YmdHis') . '.csv';
|
||||
return (new Response())->file($filepath, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $fields
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function normalizeFields(array $fields): array
|
||||
{
|
||||
$allowed = array_keys($this->fieldLabels);
|
||||
$normalized = [];
|
||||
foreach ($fields as $field) {
|
||||
$field = trim(strval($field));
|
||||
if ($field !== '' && in_array($field, $allowed, true) && !in_array($field, $normalized, true)) {
|
||||
$normalized[] = $field;
|
||||
}
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getDefaultFields(): array
|
||||
{
|
||||
return array_keys($this->fieldLabels);
|
||||
}
|
||||
|
||||
public function getMaxExportLimit(): int
|
||||
{
|
||||
return self::MAX_EXPORT_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $fields
|
||||
* @param array<string, string> $labels
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function buildHeaderRow(array $fields, array $labels): array
|
||||
{
|
||||
$header = [];
|
||||
foreach ($fields as $field) {
|
||||
$header[] = $labels[$field] ?? $field;
|
||||
}
|
||||
return $header;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @param array<int, string> $fields
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function formatRow(array $row, array $fields): array
|
||||
{
|
||||
$line = [];
|
||||
foreach ($fields as $field) {
|
||||
$value = $row[$field] ?? '';
|
||||
if ($field === 'create_time' && $value !== '' && $value !== null) {
|
||||
$timestamp = is_numeric($value) ? intval($value) : strtotime(strval($value));
|
||||
$line[] = $timestamp > 0 ? date('Y-m-d H:i:s', $timestamp) : '';
|
||||
continue;
|
||||
}
|
||||
$line[] = $value === null ? '' : strval($value);
|
||||
}
|
||||
return $line;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function resolveFieldLabels(string $lang): array
|
||||
{
|
||||
$lang = strtolower(str_replace('_', '-', $lang));
|
||||
return $lang === 'en' ? $this->fieldLabelsEn : $this->fieldLabels;
|
||||
}
|
||||
}
|
||||
263
web/src/views/backend/mall/dailyPush/exportDialog.vue
Normal file
263
web/src/views/backend/mall/dailyPush/exportDialog.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
class="ba-operate-dialog"
|
||||
:close-on-click-modal="false"
|
||||
width="560px"
|
||||
@open="onOpen"
|
||||
>
|
||||
<template #header>
|
||||
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
|
||||
{{ t('mall.dailyPush.export_title') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert type="info" :closable="false" class="export-tip">
|
||||
{{ t('mall.dailyPush.export_tip') }}
|
||||
</el-alert>
|
||||
|
||||
<div class="export-section">
|
||||
<div class="section-title">{{ t('mall.dailyPush.export_fields') }}</div>
|
||||
<div class="field-actions">
|
||||
<el-button link type="primary" @click="selectAllFields">{{ t('mall.dailyPush.export_select_all') }}</el-button>
|
||||
<el-button link type="primary" @click="clearFields">{{ t('mall.dailyPush.export_clear_all') }}</el-button>
|
||||
</div>
|
||||
<el-checkbox-group v-model="form.fields" class="field-group">
|
||||
<el-checkbox v-for="item in fieldOptions" :key="item.value" :label="item.value" :value="item.value">
|
||||
{{ item.label }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
|
||||
<div class="export-section">
|
||||
<div class="section-title">{{ t('mall.dailyPush.export_limit') }}</div>
|
||||
<div class="limit-row">
|
||||
<el-input-number v-model="form.exportLimit" :min="1" :max="maxLimit" :step="1000" controls-position="right" />
|
||||
<div class="limit-presets">
|
||||
<el-button
|
||||
v-for="preset in limitPresets"
|
||||
:key="preset"
|
||||
size="small"
|
||||
:type="form.exportLimit === preset ? 'primary' : 'default'"
|
||||
@click="form.exportLimit = preset"
|
||||
>
|
||||
{{ preset }}
|
||||
</el-button>
|
||||
<el-button size="small" :type="form.exportLimit === matchedCount ? 'primary' : 'default'" @click="useMatchedCount">
|
||||
{{ t('mall.dailyPush.export_all_matched') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="count-info" v-loading="countLoading">
|
||||
{{ t('mall.dailyPush.export_matched_count', { count: matchedCount }) }}
|
||||
<span v-if="actualExportCount < form.exportLimit">
|
||||
,{{ t('mall.dailyPush.export_actual_count', { count: actualExportCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
<el-alert v-if="form.exportLimit > 10000" type="warning" :closable="false" class="large-export-warning">
|
||||
{{ t('mall.dailyPush.export_large_warning', { max: maxLimit }) }}
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">{{ t('Cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="exporting" :disabled="form.fields.length === 0" @click="submitExport">
|
||||
{{ t('mall.dailyPush.export_confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElNotification } from 'element-plus'
|
||||
import createAxios from '/@/utils/axios'
|
||||
import type baTableClass from '/@/utils/baTable'
|
||||
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
|
||||
|
||||
const { t } = useI18n()
|
||||
const baTable = inject('baTable') as baTableClass
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const exporting = ref(false)
|
||||
const countLoading = ref(false)
|
||||
const matchedCount = ref(0)
|
||||
const maxLimit = ref(100000)
|
||||
const limitPresets = [1000, 5000, 10000, 50000]
|
||||
|
||||
const fieldOptions = computed(() => [
|
||||
{ value: 'id', label: t('mall.dailyPush.id') },
|
||||
{ value: 'user_id', label: t('mall.dailyPush.user_id') },
|
||||
{ value: 'date', label: t('mall.dailyPush.date') },
|
||||
{ value: 'username', label: t('mall.dailyPush.username') },
|
||||
{ value: 'yesterday_win_loss_net', label: t('mall.dailyPush.yesterday_win_loss_net') },
|
||||
{ value: 'yesterday_total_deposit', label: t('mall.dailyPush.yesterday_total_deposit') },
|
||||
{ value: 'lifetime_total_deposit', label: t('mall.dailyPush.lifetime_total_deposit') },
|
||||
{ value: 'lifetime_total_withdraw', label: t('mall.dailyPush.lifetime_total_withdraw') },
|
||||
{ value: 'create_time', label: t('mall.dailyPush.create_time') },
|
||||
])
|
||||
|
||||
const form = reactive({
|
||||
fields: fieldOptions.value.map((item) => item.value),
|
||||
exportLimit: 10000,
|
||||
})
|
||||
|
||||
const actualExportCount = computed(() => Math.min(form.exportLimit, matchedCount.value, maxLimit.value))
|
||||
|
||||
const selectAllFields = () => {
|
||||
form.fields = fieldOptions.value.map((item) => item.value)
|
||||
}
|
||||
|
||||
const clearFields = () => {
|
||||
form.fields = []
|
||||
}
|
||||
|
||||
const useMatchedCount = () => {
|
||||
form.exportLimit = Math.min(matchedCount.value || 1, maxLimit.value)
|
||||
}
|
||||
|
||||
const buildExportParams = () => {
|
||||
const filter = baTable.table.filter || {}
|
||||
return {
|
||||
...filter,
|
||||
fields: form.fields,
|
||||
export_limit: actualExportCount.value,
|
||||
}
|
||||
}
|
||||
|
||||
const loadMatchedCount = async () => {
|
||||
countLoading.value = true
|
||||
try {
|
||||
const res = await createAxios<{ count: number; max_limit: number }>({
|
||||
url: '/admin/mall.DailyPush/exportCount',
|
||||
method: 'get',
|
||||
params: baTable.table.filter || {},
|
||||
})
|
||||
matchedCount.value = res.data.count
|
||||
maxLimit.value = res.data.max_limit
|
||||
} catch {
|
||||
matchedCount.value = baTable.table.total || 0
|
||||
} finally {
|
||||
countLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const parseBlobError = async (blob: Blob): Promise<string> => {
|
||||
try {
|
||||
const text = await blob.text()
|
||||
const json = JSON.parse(text)
|
||||
if (json && typeof json.msg === 'string' && json.msg !== '') {
|
||||
return json.msg
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return t('mall.dailyPush.export_failed')
|
||||
}
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const submitExport = async () => {
|
||||
if (form.fields.length === 0) {
|
||||
return
|
||||
}
|
||||
exporting.value = true
|
||||
try {
|
||||
const response = await createAxios<Blob>(
|
||||
{
|
||||
url: '/admin/mall.DailyPush/export',
|
||||
method: 'get',
|
||||
params: buildExportParams(),
|
||||
responseType: 'blob',
|
||||
timeout: 0,
|
||||
},
|
||||
{
|
||||
reductDataFormat: false,
|
||||
showErrorMessage: false,
|
||||
cancelDuplicateRequest: false,
|
||||
}
|
||||
)
|
||||
|
||||
const blob = response.data
|
||||
const contentType = String(response.headers['content-type'] || '')
|
||||
if (contentType.includes('application/json')) {
|
||||
throw new Error(await parseBlobError(blob))
|
||||
}
|
||||
|
||||
const disposition = String(response.headers['content-disposition'] || '')
|
||||
const filenameMatch = disposition.match(/filename="?([^";]+)"?/i)
|
||||
const filename = filenameMatch?.[1] || `daily_push_${Date.now()}.csv`
|
||||
downloadBlob(blob, filename)
|
||||
|
||||
ElNotification({
|
||||
type: 'success',
|
||||
message: t('mall.dailyPush.export_success', { count: actualExportCount.value }),
|
||||
zIndex: SYSTEM_ZINDEX,
|
||||
})
|
||||
visible.value = false
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('mall.dailyPush.export_failed')
|
||||
ElNotification({
|
||||
type: 'error',
|
||||
message,
|
||||
zIndex: SYSTEM_ZINDEX,
|
||||
})
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onOpen = () => {
|
||||
loadMatchedCount()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.export-tip {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.export-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.field-actions {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.field-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px 12px;
|
||||
}
|
||||
.limit-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.limit-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.count-info {
|
||||
margin-top: 10px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.large-export-warning {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user