feat(pickup): 支持线下提号 CSV 导出

This commit is contained in:
yml
2026-11-12 14:53:36 +08:00
parent a5601eb76c
commit e8a281fce6
8 changed files with 628 additions and 2 deletions
+1
View File
@@ -46,6 +46,7 @@ var (
ErrPickupShopNameExists = errors.New("pickup shop name already exists")
ErrPickupCancelled = errors.New("pickup is cancelled")
ErrInvalidThirdPartyOrderNo = errors.New("invalid third party order no")
ErrPickupExportTooLarge = errors.New("pickup export too large")
)
type PickupDTO struct {
+256
View File
@@ -0,0 +1,256 @@
package pickup
import (
"bytes"
"context"
"encoding/csv"
"fmt"
"strings"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/timeutil"
"gorm.io/gorm"
)
const maxAdminPickupExportRows = 50000
var adminPickupExportHeader = []string{
"提号编号",
"账号标题",
"区服",
"登录平台",
"上架编号",
"卖家手机号",
"操作人",
"账号来源",
"来源渠道",
"交易渠道",
"第三方单号",
"店铺名称",
"提号备注",
"下单金额(元)",
"押金金额(元)",
"线下利润(元)",
"结算金额(元)",
"提号状态",
"结算状态",
"创建时间",
"完成时间",
"取消时间",
"完成备注",
}
// ListAdminForExport 按后台列表相同条件查询全部匹配提号,不返回账号快照。
func (r *Repository) ListAdminForExport(ctx context.Context, query AdminPickupQuery) ([]PickupDTO, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
var rows []pickupRow
db := applyAdminPickupFilters(r.adminPickupExportQuery(ctx), query)
if err := db.Order("p.id DESC").Limit(maxAdminPickupExportRows + 1).Scan(&rows).Error; err != nil {
return nil, err
}
if len(rows) > maxAdminPickupExportRows {
return nil, ErrPickupExportTooLarge
}
items := make([]PickupDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO())
}
return items, nil
}
// RecordAdminPickupExport 记录敏感数据导出行为。
func (r *Repository) RecordAdminPickupExport(
ctx context.Context,
adminID uint64,
meta auditlog.Meta,
query AdminPickupQuery,
rowCount int,
) error {
if r == nil || r.db == nil {
return ErrDependencyUnavailable
}
return auditlog.Append(r.db.WithContext(ctx), auditlog.Entry{
ActorType: "admin",
ActorID: adminID,
Action: "pickup_export",
BizType: "admin_pickup",
Meta: meta,
Detail: map[string]any{
"row_count": rowCount,
"keyword": strings.TrimSpace(query.Keyword),
"status": strings.TrimSpace(query.Status),
"shop_name": strings.TrimSpace(query.ShopName),
"account_source": strings.TrimSpace(query.AccountSource),
"offline_settlement_status": strings.TrimSpace(query.OfflineSettlementStatus),
"operator": strings.TrimSpace(query.Operator),
"third_party_order_no": strings.TrimSpace(query.ThirdPartyOrderNo),
"start_time": formatExportTimePointer(query.StartTime),
"end_time": formatExportTimePointer(query.EndTime),
},
})
}
func (r *Repository) adminPickupExportQuery(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).Table("admin_pickups AS p").
Select(`p.id, p.pickup_no, p.listing_id, p.account_id, p.owner_id, p.admin_id,
p.operator, p.platform, p.third_party_order_no, p.shop_name, p.raw_shop_name,
p.account_source, p.source_channel, p.settlement_mode,
p.order_amount_cent, p.deposit_amount_cent, p.profit_amount_cent, p.settle_amount_cent,
p.status, p.offline_settlement_status, p.remark, p.complete_remark,
p.created_at, p.completed_at, p.cancelled_at,
l.listing_no, a.title AS account_title, a.server_region, a.login_platform, owner.phone AS owner_phone,
COALESCE((SELECT SUM(fa.profit_delta_cent) FROM admin_pickup_financial_adjustments AS fa WHERE fa.pickup_id = p.id), 0) AS profit_adjustment_cent,
COALESCE((SELECT SUM(fa.settle_delta_cent) FROM admin_pickup_financial_adjustments AS fa WHERE fa.pickup_id = p.id), 0) AS settle_adjustment_cent`).
Joins("JOIN rental_listings AS l ON l.id = p.listing_id").
Joins("JOIN game_accounts AS a ON a.id = p.account_id").
Joins("JOIN users AS owner ON owner.id = p.owner_id")
}
func buildAdminPickupCSV(items []PickupDTO) ([]byte, error) {
var buf bytes.Buffer
buf.WriteString("\xEF\xBB\xBF")
writer := csv.NewWriter(&buf)
writer.UseCRLF = true
if err := writer.Write(adminPickupExportHeader); err != nil {
return nil, err
}
for _, item := range items {
settleAmount := ""
if item.Status == StatusCompleted {
settleAmount = formatExportAmount(item.EffectiveSettleAmountCent)
}
record := []string{
exportCSVText(item.PickupNo),
exportCSVText(item.AccountTitle),
exportCSVText(item.ServerRegion),
exportCSVText(item.LoginPlatform),
exportCSVText(item.ListingNo),
exportCSVText(item.OwnerPhone),
exportCSVText(item.Operator),
exportAccountSourceLabel(item.AccountSource),
exportCSVText(item.SourceChannel),
exportCSVText(item.Platform),
exportCSVText(item.ThirdPartyOrderNo),
exportCSVText(item.ShopName),
exportCSVText(item.Remark),
formatExportAmount(item.OrderAmountCent),
formatExportAmount(item.DepositAmountCent),
formatExportAmount(item.EffectiveProfitAmountCent),
settleAmount,
exportPickupStatusLabel(item.Status),
exportSettlementStatusLabel(item),
formatExportTime(item.CreatedAt),
formatExportTimePointer(item.CompletedAt),
formatExportTimePointer(item.CancelledAt),
exportCSVText(item.CompleteRemark),
}
if err := writer.Write(record); err != nil {
return nil, err
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func exportCSVText(value string) string {
if value == "" {
return ""
}
trimmed := strings.TrimLeft(value, " \t\r")
if trimmed == "" {
return value
}
switch trimmed[0] {
case '=', '+', '-', '@', '\t', '\r':
return "'" + value
default:
return value
}
}
func exportPickupStatusLabel(status string) string {
switch status {
case StatusPickingUp:
return "提号中"
case StatusCompleted:
return "已完成"
case StatusCancelled:
return "已取消"
default:
return status
}
}
func exportAccountSourceLabel(source string) string {
switch source {
case AccountSourceExternalPlatformManaged:
return "外部上传 · 平台代管"
case AccountSourceInternal:
return "站内上传"
default:
return source
}
}
func exportSettlementStatusLabel(item PickupDTO) string {
if item.SettlementMode != SettlementModePlatformManaged {
return "钱包已结算"
}
switch item.OfflineSettlementStatus {
case OfflineSettlementStatusPending:
return "待线下结算"
case OfflineSettlementStatusSettled:
return "已线下结算"
default:
return "待完成提号"
}
}
func formatExportAmount(cent int64) string {
if cent < 0 {
return "-" + formatExportAmount(-cent)
}
return fmt.Sprintf("%d.%02d", cent/100, cent%100)
}
func formatExportTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.In(timeutil.ShanghaiLocation()).Format("2006-01-02 15:04:05")
}
func formatExportTimePointer(value *time.Time) string {
if value == nil {
return ""
}
return formatExportTime(*value)
}
func encodeExportFilename(filename string) string {
var builder strings.Builder
for _, value := range []byte(filename) {
if (value >= 'a' && value <= 'z') ||
(value >= 'A' && value <= 'Z') ||
(value >= '0' && value <= '9') ||
value == '-' || value == '_' || value == '.' || value == '~' {
builder.WriteByte(value)
continue
}
builder.WriteByte('%')
builder.WriteString(strings.ToUpper(fmt.Sprintf("%02X", value)))
}
return builder.String()
}
@@ -0,0 +1,196 @@
package pickup
import (
"bytes"
"encoding/csv"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/model"
"github.com/gin-gonic/gin"
)
func TestBuildAdminPickupCSVUsesExcelFriendlyEncodingAndEscaping(t *testing.T) {
createdAt := time.Date(2026, 11, 12, 10, 20, 30, 0, time.FixedZone("CST", 8*60*60))
completedAt := createdAt.Add(2 * time.Hour)
items := []PickupDTO{
{
PickupNo: "PK-001",
AccountTitle: "账号,带逗号",
ServerRegion: "国服",
LoginPlatform: "QQ",
ListingNo: "202611120001",
OwnerPhone: "18800000000",
Operator: "运营小张",
AccountSource: AccountSourceExternalPlatformManaged,
SourceChannel: "闲鱼",
Platform: "拼多多",
ThirdPartyOrderNo: "=1+1",
ShopName: `大锤"专营店"`,
Remark: "第一行\n第二行",
OrderAmountCent: 29500,
DepositAmountCent: 35000,
EffectiveProfitAmountCent: 1234,
EffectiveSettleAmountCent: 20000,
SettlementMode: SettlementModePlatformManaged,
OfflineSettlementStatus: OfflineSettlementStatusPending,
Status: StatusCompleted,
CreatedAt: createdAt,
CompletedAt: &completedAt,
CompleteRemark: "已完成",
},
}
data, err := buildAdminPickupCSV(items)
if err != nil {
t.Fatalf("buildAdminPickupCSV() error = %v", err)
}
if !bytes.HasPrefix(data, []byte{0xEF, 0xBB, 0xBF}) {
t.Fatal("CSV 缺少 UTF-8 BOM")
}
records, err := csv.NewReader(bytes.NewReader(data[3:])).ReadAll()
if err != nil {
t.Fatalf("解析 CSV 失败: %v", err)
}
if len(records) != 2 {
t.Fatalf("CSV 行数 = %d, want 2", len(records))
}
if len(records[0]) != len(adminPickupExportHeader) {
t.Fatalf("表头列数 = %d, want %d", len(records[0]), len(adminPickupExportHeader))
}
row := records[1]
if row[1] != "账号,带逗号" {
t.Fatalf("账号标题 = %q", row[1])
}
if row[7] != "外部上传 · 平台代管" {
t.Fatalf("账号来源 = %q", row[7])
}
if row[10] != "'=1+1" {
t.Fatalf("第三方单号未转义公式注入: %q", row[10])
}
if row[11] != `大锤"专营店"` {
t.Fatalf("店铺名称 = %q", row[11])
}
if row[12] != "第一行\n第二行" {
t.Fatalf("提号备注 = %q", row[12])
}
if row[13] != "295.00" || row[14] != "350.00" || row[15] != "12.34" {
t.Fatalf("金额列 = %q/%q/%q", row[13], row[14], row[15])
}
if row[16] != "200.00" {
t.Fatalf("结算金额 = %q", row[16])
}
if row[17] != "已完成" || row[18] != "待线下结算" {
t.Fatalf("状态列 = %q/%q", row[17], row[18])
}
if row[19] != "2026-11-12 10:20:30" || row[20] != "2026-11-12 12:20:30" {
t.Fatalf("时间列 = %q/%q", row[19], row[20])
}
}
func TestServiceExportAdminReusesFiltersAndRecordsAudit(t *testing.T) {
repo, db := newPickupTestRepo(t)
first := seedPickup(t, db, StatusPickingUp)
if err := db.Model(&first).Updates(map[string]any{
"operator": "运营,小张",
"platform": "拼多多",
"third_party_order_no": "=1+1",
"order_amount_cent": 29500,
"deposit_amount_cent": 35000,
}).Error; err != nil {
t.Fatalf("更新第一条提号失败: %v", err)
}
second := first
second.ID = 0
second.PickupNo = "PK-EXPORT-002"
second.Status = StatusCompleted
second.Operator = "运营小李"
second.ThirdPartyOrderNo = "TB-002"
if err := db.Create(&second).Error; err != nil {
t.Fatalf("创建第二条提号失败: %v", err)
}
service := NewService(repo)
data, rowCount, err := service.ExportAdmin(
t.Context(),
AdminPickupQuery{Status: StatusPickingUp},
99,
auditlog.Meta{RequestID: "req-pickup-export"},
)
if err != nil {
t.Fatalf("ExportAdmin() error = %v", err)
}
if rowCount != 1 {
t.Fatalf("导出行数 = %d, want 1", rowCount)
}
records, err := csv.NewReader(bytes.NewReader(data[3:])).ReadAll()
if err != nil {
t.Fatalf("解析导出 CSV 失败: %v", err)
}
if len(records) != 2 {
t.Fatalf("筛选导出 CSV 行数 = %d, want 2", len(records))
}
if records[1][0] != first.PickupNo || records[1][10] != "'=1+1" {
t.Fatalf("筛选导出行 = %#v", records[1])
}
var audit model.AuditLog
if err := db.Where("action = ?", "pickup_export").First(&audit).Error; err != nil {
t.Fatalf("查询导出审计失败: %v", err)
}
var detail map[string]any
if err := json.Unmarshal(audit.Detail, &detail); err != nil {
t.Fatalf("解析导出审计失败: %v", err)
}
if detail["status"] != StatusPickingUp {
t.Fatalf("审计 status = %v, want %s", detail["status"], StatusPickingUp)
}
assertJSONNumber(t, "导出行数", detail["row_count"], 1)
if !strings.Contains(string(audit.Detail), "req-pickup-export") {
t.Fatalf("审计日志缺少 request_id: %s", audit.Detail)
}
}
func TestHandlerExportWritesCSVDownloadResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
repo, db := newPickupTestRepo(t)
seedPickup(t, db, StatusPickingUp)
handler := NewHandler(NewService(repo))
router := gin.New()
router.GET("/export", func(c *gin.Context) {
c.Set(middleware.ContextAdminID, uint64(99))
handler.Export(c)
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/export", nil)
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("状态码 = %d, want %d, body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
if contentType := recorder.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/csv") {
t.Fatalf("Content-Type = %q", contentType)
}
if disposition := recorder.Header().Get("Content-Disposition"); !strings.Contains(disposition, ".csv") {
t.Fatalf("Content-Disposition = %q", disposition)
}
if rowCount := recorder.Header().Get("X-Export-Row-Count"); rowCount != "1" {
t.Fatalf("X-Export-Row-Count = %q, want 1", rowCount)
}
if !bytes.HasPrefix(recorder.Body.Bytes(), []byte{0xEF, 0xBB, 0xBF}) {
t.Fatal("响应体缺少 UTF-8 BOM")
}
}
@@ -2,11 +2,13 @@ package pickup
import (
"errors"
"fmt"
"net/http"
"strings"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/timeutil"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
@@ -244,6 +246,31 @@ func (h *Handler) List(c *gin.Context) {
response.OK(c, result)
}
// Export 导出线下提号 CSV(管理员)。
func (h *Handler) Export(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
query := parseAdminQuery(c)
data, rowCount, err := h.service.ExportAdmin(c.Request.Context(), query, adminID, auditMeta(c))
if err != nil {
writePickupError(c, err)
return
}
timestamp := timeutil.ShanghaiNow().Format("20060102-150405")
filename := fmt.Sprintf("线下提号_%s.csv", timestamp)
asciiFilename := fmt.Sprintf("pickup-export-%s.csv", timestamp)
c.Header(
"Content-Disposition",
fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, asciiFilename, encodeExportFilename(filename)),
)
c.Header("X-Export-Row-Count", fmt.Sprintf("%d", rowCount))
c.Data(http.StatusOK, "text/csv; charset=utf-8", data)
}
// ShopOptions 返回后台提号已使用的店铺名称。
func (h *Handler) ShopOptions(c *gin.Context) {
items, err := h.service.ListShopNames(c.Request.Context(), c.Query("platform"))
@@ -333,6 +360,13 @@ func writePickupError(c *gin.Context, err error) {
response.BadRequest(c, "提号订单已取消,不能补录单号")
case errors.Is(err, ErrInvalidThirdPartyOrderNo):
response.BadRequest(c, "第三方单号不能为空且长度不能超过128位")
case errors.Is(err, ErrPickupExportTooLarge):
response.Error(
c,
http.StatusBadRequest,
"pickup_export_too_large",
fmt.Sprintf("导出数据超过 %d 条,请缩小筛选范围", maxAdminPickupExportRows),
)
default:
response.Error(c, http.StatusInternalServerError, "pickup_error", err.Error())
}
+23 -1
View File
@@ -141,6 +141,29 @@ func (s *Service) ListAdmin(ctx context.Context, query AdminPickupQuery) (*Pagin
return s.repo.ListAdmin(ctx, query)
}
func (s *Service) ExportAdmin(
ctx context.Context,
query AdminPickupQuery,
adminID uint64,
meta auditlog.Meta,
) ([]byte, int, error) {
if s.repo == nil {
return nil, 0, ErrDependencyUnavailable
}
items, err := s.repo.ListAdminForExport(ctx, query)
if err != nil {
return nil, 0, err
}
data, err := buildAdminPickupCSV(items)
if err != nil {
return nil, 0, err
}
if err := s.repo.RecordAdminPickupExport(ctx, adminID, meta, query, len(items)); err != nil {
return nil, 0, err
}
return data, len(items), nil
}
func (s *Service) ListShopNames(ctx context.Context, platform string) ([]string, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
@@ -210,4 +233,3 @@ func (s *Service) DeleteShopMapping(ctx context.Context, sourceName string, admi
}
return s.repo.DeleteShopMapping(ctx, sourceName, adminID, meta)
}
+1
View File
@@ -840,6 +840,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
// 管理员线下提号(独立于正常订单流程)
adminRoutes.POST("/pickups", requirePerm("order:pickup"), pickupHandler.Create)
adminRoutes.GET("/pickups", requirePerm("order:pickup"), pickupHandler.List)
adminRoutes.GET("/pickups/export", requirePerm("order:pickup"), pickupHandler.Export)
adminRoutes.GET("/pickups/shop-options", requirePerm("order:pickup"), pickupHandler.ShopOptions)
adminRoutes.GET("/pickups/shops", requirePerm("order:pickup"), pickupHandler.ListShops)
adminRoutes.POST("/pickups/shops", requirePerm("order:pickup"), pickupHandler.CreateShop)
@@ -1,3 +1,4 @@
import axios from 'axios'
import { apiClient } from '@/shared/api/client'
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
@@ -166,6 +167,8 @@ export interface AdminPickupListQuery {
end_time?: string
}
export type AdminPickupExportQuery = Omit<AdminPickupListQuery, 'page' | 'page_size'>
export interface PickupShop {
id: number
name: string
@@ -254,11 +257,57 @@ export async function fetchAdminPickups(query: AdminPickupListQuery = {}) {
}
}
export async function exportAdminPickups(query: AdminPickupExportQuery = {}) {
try {
const response = await apiClient.get<Blob>('/admin/pickups/export', {
params: cleanParams(query),
responseType: 'blob',
timeout: 60000,
skipErrorHandler: true,
})
return {
blob: response.data,
filename: readExportFilename(response.headers['content-disposition']),
rowCount: Number(response.headers['x-export-row-count'] || 0),
}
} catch (error) {
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
const text = await error.response.data.text()
let message = ''
try {
const payload = JSON.parse(text) as { message?: unknown }
if (typeof payload.message === 'string') {
message = payload.message
}
} catch {
// 非 JSON 错误响应保留原始 Axios 错误。
}
if (message) {
error.message = message
}
}
throw error
}
}
export async function fetchAdminPickup(id: string | number) {
const { data } = await apiClient.get<ApiResponse<AdminPickup>>(`/admin/pickups/${id}`)
return data.data
}
function readExportFilename(contentDisposition: unknown) {
if (typeof contentDisposition !== 'string') return 'pickup-export.csv'
const encoded = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
if (encoded) {
try {
return decodeURIComponent(encoded)
} catch {
return encoded
}
}
return contentDisposition.match(/filename="?([^";]+)"?/i)?.[1] || 'pickup-export.csv'
}
export async function fetchAdminPickupShopOptions(platform = '') {
const { data } = await apiClient.get<ApiResponse<string[]>>('/admin/pickups/shop-options', {
params: cleanParams({ platform }),
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { EditPen, Plus, Refresh, Search, Setting, Shop } from '@element-plus/icons-vue'
import { Download, EditPen, Plus, Refresh, Search, Setting, Shop } from '@element-plus/icons-vue'
import {
cancelAdminPickup,
confirmAdminPickupOfflineSettlement,
completeAdminPickup,
createAdminPickupFinancialAdjustment,
createAdminPickup,
exportAdminPickups,
fetchAdminPickups,
fetchAdminPickupShopOptions,
fetchAvailableListings,
@@ -30,6 +31,7 @@ const adminSession = useAdminSessionStore()
const currentAdminName = computed(() => adminSession.nickname || adminSession.username || '')
const loading = ref(false)
const exporting = ref(false)
const items = ref<AdminPickup[]>([])
const total = ref(0)
const filterTimeRange = ref<[string, string] | null>(null)
@@ -44,6 +46,16 @@ const filters = reactive({
start_time: '',
end_time: '',
})
const hasActiveFilters = computed(
() =>
Boolean(filters.keyword.trim()) ||
Boolean(filters.status) ||
Boolean(filters.shop_name) ||
Boolean(filters.account_source) ||
Boolean(filters.offline_settlement_status) ||
Boolean(filters.start_time) ||
Boolean(filters.end_time)
)
const createDialogVisible = ref(false)
const completeDialogVisible = ref(false)
@@ -153,6 +165,48 @@ function handleSizeChange(s: number) {
loadList()
}
async function handleExportCommand(command: string) {
const exportAll = command === 'all'
if (!exportAll && hasActiveFilters.value && total.value === 0) {
ElMessage.warning('当前筛选条件下没有可导出的数据')
return
}
exporting.value = true
try {
const result = await exportAdminPickups(
exportAll
? {}
: {
keyword: filters.keyword.trim(),
status: filters.status,
shop_name: filters.shop_name,
account_source: filters.account_source,
offline_settlement_status: filters.offline_settlement_status,
start_time: filters.start_time,
end_time: filters.end_time,
}
)
downloadBlob(result.blob, result.filename)
ElMessage.success(result.rowCount > 0 ? `已导出 ${result.rowCount} 条提号记录` : '导出成功')
} catch (e: unknown) {
ElMessage.error(errorMessage(e) || '导出失败')
} finally {
exporting.value = false
}
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
function openCreateDialog() {
createForm.listing_id = null
createForm.operator = currentAdminName.value || ''
@@ -531,6 +585,19 @@ function listingOptionLabel(item: AvailableListing) {
<p>管理员线下提号,站内上传结算到钱包,平台代管通过线下结算确认打款。</p>
</div>
<div class="toolbar-actions">
<el-dropdown
trigger="click"
:disabled="exporting || loading"
@command="handleExportCommand"
>
<el-button :icon="Download" :loading="exporting">导出 CSV</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="filtered">导出筛选结果</el-dropdown-item>
<el-dropdown-item command="all">导出全部</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button :icon="Shop" @click="shopMgrVisible = true">店铺管理</el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDialog">创建提号</el-button>
</div>