feat(ecommerce): 店铺收款账户账单·其余6个账户类型页(通用配置驱动)

按「账单抬头.xlsx」各子表(微信/拼多多/京东网银钱包/京东流水/抖音/得物)设计对应 tab 页:
- 新增通用表 ec_account_bills(bill_type + 通用列 + payload JSON)
- AccountBill 模型 + AccountBillColumns 配置类(6类型列定义,单一数据源,sheet名↔tab↔列不乱)
- AccountBills 控制器(admin 路由组,按 bill_type 筛选/排序/分页+汇总+下发列配置)
- admin 路由登记 account-bills
- 通用前端组件 AccountBills.vue(按后端下发 columns 动态渲染表头/单元格,含筛选/汇总/分页)
- pages.js:store-receiving-bills 页按 tab 路由(taobao-alipay→独立组件;其余6个→AccountBills)
- 修正 pages.js/Blade 的 section/page/tab 解析(按真实URL billing-transactions/store-receiving-bills/<tab>)
- 种子:6类型各6行演示数据
淘系支付宝账户保持独立实现不变。
This commit is contained in:
xuxiake 2026-07-21 18:34:24 +08:00
parent c934f5f12c
commit 74ddc6c88e
9 changed files with 955 additions and 16 deletions

View File

@ -0,0 +1,236 @@
<?php
namespace Modules\EcommerceFinance;
/**
* 店铺收款账户账单 - 各账户类型的列配置(单一数据源)。
*
* 列定义来自「账单抬头.xlsx」的各子表(sheet 名 = 账户类型)。
* sheet 名 ↔ tab slug ↔ 列 三者在此一一对应,避免错乱。
*
* 每列: [label, field, kind]
* field: 真实列名(如 record_code / income / occurred_at / updated_at),
* 或 'payload.xxx' 表示存在 payload JSON 中。
* kind: text | num | money | money_in | money_out | balance | date | tag
*
* 淘系支付宝账户(taobao-alipay)已有独立实现,不在此处定义。
*/
class AccountBillColumns
{
/**
* @return array<string,array{title:string,columns:array<int,array{0:string,1:string,2:string}>}>
*/
public static function map(): array
{
return [
'taobao-wechat' => [
'title' => '淘系微信账户',
'columns' => [
['记录编码', 'record_code', 'num'],
['店铺', 'store_name', 'text'],
['财务备注', 'finance_remark', 'text'],
['订单号', 'order_no', 'num'],
['金额', 'amount', 'money'],
['自定义备注', 'custom_remark', 'text'],
['账单项目', 'bill_item', 'tag'],
['线上单号', 'payload.online_order_no', 'num'],
['淘宝订单编号', 'payload.taobao_order_no', 'num'],
['支付流水号', 'payload.pay_serial_no', 'num'],
['入账时间', 'occurred_at', 'date'],
['入账类型', 'payload.entry_type', 'text'],
['收入金额', 'income', 'money_in'],
['支出金额', 'expense', 'money_out'],
['业务描述', 'payload.business_desc', 'text'],
['备注', 'remark', 'text'],
['JST拉取时间', 'payload.jst_pulled_at', 'date'],
['修改人', 'modifier', 'text'],
],
],
'pinduoduo' => [
'title' => '拼多多账户',
'columns' => [
['记录编码', 'record_code', 'num'],
['店铺', 'store_name', 'text'],
['财务备注', 'finance_remark', 'text'],
['订单', 'order_no', 'num'],
['金额', 'amount', 'money'],
['自定义备注', 'custom_remark', 'text'],
['账单项目', 'bill_item', 'tag'],
['商户订单号', 'payload.merchant_order_no', 'num'],
['线上单号', 'payload.online_order_no', 'num'],
['财务类型', 'payload.finance_type', 'text'],
['类型', 'payload.type', 'text'],
['发生时间', 'occurred_at', 'date'],
['收入金额', 'income', 'money_in'],
['支出金额', 'expense', 'money_out'],
['备注', 'remark', 'text'],
['修改时间', 'updated_at', 'date'],
['修改人', 'modifier', 'text'],
],
],
'jd-wallet' => [
'title' => '京东网银钱包账户',
'columns' => [
['记录编码', 'record_code', 'num'],
['店铺', 'store_name', 'text'],
['财务备注', 'finance_remark', 'text'],
['订单', 'order_no', 'num'],
['金额', 'amount', 'money'],
['自定义备注', 'custom_remark', 'text'],
['账单项目', 'bill_item', 'tag'],
['线上单号', 'payload.online_order_no', 'num'],
['单据编号', 'payload.doc_no', 'num'],
['订单编号', 'payload.order_no_2', 'num'],
['商户订单号', 'payload.merchant_order_no', 'num'],
['单据类型', 'payload.doc_type', 'text'],
['商品编码', 'payload.goods_code', 'num'],
['商品名称', 'payload.goods_name', 'text'],
['费用结算时间', 'occurred_at', 'date'],
['费用发生时间', 'payload.fee_occurred_at', 'date'],
['费用项', 'payload.fee_item', 'text'],
['收入金额', 'income', 'money_in'],
['支出金额', 'expense', 'money_out'],
['币种', 'payload.currency', 'text'],
['钱包结算备注', 'payload.wallet_remark', 'text'],
['备注', 'remark', 'text'],
['店铺号', 'payload.store_no', 'num'],
['京东门店编码', 'payload.jd_store_code', 'num'],
['品牌门店编号', 'payload.brand_store_no', 'num'],
['门店名称', 'payload.store_name_2', 'text'],
['账户代码', 'payload.account_code', 'num'],
['修改时间', 'updated_at', 'date'],
['修改人', 'modifier', 'text'],
],
],
'jd-transactions' => [
'title' => '京东流水',
'columns' => [
['记录编码', 'record_code', 'num'],
['店铺', 'store_name', 'text'],
['财务备注', 'finance_remark', 'text'],
['订单', 'order_no', 'num'],
['金额', 'amount', 'money'],
['自定义备注', 'custom_remark', 'text'],
['账单项目', 'bill_item', 'tag'],
['线上单号', 'payload.online_order_no', 'num'],
['流水号', 'payload.serial_no', 'num'],
['交易订单号', 'payload.tx_order_no', 'num'],
['账户名称', 'payload.account_name', 'text'],
['费用结算时间', 'occurred_at', 'date'],
['账单日期', 'payload.bill_date', 'date'],
['商户订单号', 'payload.merchant_order_no', 'num'],
['交易备注', 'payload.tx_remark', 'text'],
['交易类型', 'payload.tx_type', 'text'],
['收入金额', 'income', 'money_in'],
['支出金额', 'expense', 'money_out'],
['账户余额', 'balance', 'balance'],
['币种', 'payload.currency', 'text'],
['记账请求号', 'payload.book_request_no', 'num'],
['业务订单号', 'payload.business_order_no', 'num'],
['修改时间', 'updated_at', 'date'],
['修改人', 'modifier', 'text'],
],
],
'douyin' => [
'title' => '抖音账户',
'columns' => [
['记录编码', 'record_code', 'num'],
['店铺', 'store_name', 'text'],
['财务备注', 'finance_remark', 'text'],
['订单', 'order_no', 'num'],
['金额', 'amount', 'money'],
['自定义备注', 'custom_remark', 'text'],
['商户主体名称', 'payload.merchant_name', 'text'],
['账单项目', 'bill_item', 'tag'],
['动账流水号', 'payload.move_serial_no', 'num'],
['订单号', 'payload.order_no_2', 'num'],
['线上单号', 'payload.online_order_no', 'num'],
['子订单号', 'payload.sub_order_no', 'num'],
['商品ID', 'payload.goods_id', 'num'],
['账务类型', 'payload.account_type', 'text'],
['结算时间', 'occurred_at', 'date'],
['动账摘要', 'payload.move_summary', 'text'],
['下单时间', 'payload.order_placed_at', 'date'],
['业务类型', 'payload.business_type', 'text'],
['结算账户', 'payload.settle_account', 'text'],
['动账场景', 'payload.move_scene', 'text'],
['达人ID', 'payload.talent_id', 'num'],
['达人名称', 'payload.talent_name', 'text'],
['备注', 'remark', 'text'],
['收入', 'income', 'money_in'],
['支出', 'expense', 'money_out'],
['创建时间', 'created_at', 'date'],
['修改时间', 'updated_at', 'date'],
['修改人', 'modifier', 'text'],
],
],
'dewu' => [
'title' => '得物账户',
'columns' => [
['记录编码', 'record_code', 'num'],
['店铺', 'store_name', 'text'],
['财务备注', 'finance_remark', 'text'],
['金额', 'amount', 'money'],
['订单号', 'order_no', 'num'],
['线上单号', 'payload.online_order_no', 'num'],
['自定义备注', 'custom_remark', 'text'],
['账单项目', 'bill_item', 'tag'],
['店铺', 'payload.store_2', 'text'],
['订单类型', 'payload.order_type', 'text'],
['商品名称', 'payload.goods_name', 'text'],
['商品货号', 'payload.goods_no', 'num'],
['数量', 'payload.quantity', 'num'],
['渠道', 'payload.channel', 'text'],
['业务类型', 'payload.business_type', 'text'],
['收入金额', 'income', 'money_in'],
['支出金额', 'expense', 'money_out'],
['线上单号对应实际店铺', 'payload.online_real_store', 'text'],
['订单创建时间', 'payload.order_created_at', 'date'],
['支付/退款时间', 'occurred_at', 'date'],
['发货/退货时间', 'payload.ship_return_at', 'date'],
['结算状态', 'payload.settle_status', 'text'],
['收款账期', 'payload.pay_period', 'text'],
['拉取时间', 'payload.pulled_at', 'date'],
['修改时间', 'updated_at', 'date'],
['修改人', 'modifier', 'text'],
],
],
];
}
public static function types(): array
{
return array_keys(self::map());
}
public static function has(string $type): bool
{
return isset(self::map()[$type]);
}
/**
* @return array{title:string,columns:array<int,array{label:string,field:string,kind:string}>}
*/
public static function for(string $type): ?array
{
$map = self::map();
if (! isset($map[$type])) {
return null;
}
$columns = array_map(function ($c) {
return ['label' => $c[0], 'field' => $c[1], 'kind' => $c[2]];
}, $map[$type]['columns']);
return [
'title' => $map[$type]['title'],
'columns' => $columns,
];
}
}

View File

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 店铺收款账户账单 - 通用账单表(覆盖淘系微信/拼多多/京东网银钱包/京东流水/抖音/得物)。
* 淘系支付宝账户(taobao-alipay)使用独立的 ec_alipay_bills 表,不在此表。
* 各类型特有的列存于 payload JSON;列定义见 Modules\EcommerceFinance\AccountBillColumns。
*/
public function up(): void
{
Schema::create('ec_account_bills', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->string('bill_type', 32); // taobao-wechat/pinduoduo/jd-wallet/jd-transactions/douyin/dewu
// 通用列(各类型共有 / 用于筛选汇总)
$table->string('record_code', 64);
$table->string('store_name', 255);
$table->string('finance_remark', 255)->nullable(); // 财务备注
$table->string('order_no', 64)->nullable(); // 订单 / 订单号
$table->decimal('amount', 20, 6)->nullable(); // 金额
$table->string('custom_remark', 255)->nullable(); // 自定义备注
$table->string('bill_item', 64)->nullable(); // 账单项目
$table->dateTime('occurred_at')->nullable(); // 主时间(入账/发生/结算...)
$table->decimal('income', 20, 6)->default(0); // 收入(金额)
$table->decimal('expense', 20, 6)->default(0); // 支出(金额)
$table->decimal('balance', 20, 6)->default(0); // 余额/账户余额
$table->string('remark', 512)->nullable(); // 备注
$table->string('modifier', 64)->nullable(); // 修改人
$table->json('payload')->nullable(); // 各类型特有列
$table->unsignedInteger('created_by')->nullable();
$table->string('created_from', 100)->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'bill_type']);
$table->index(['company_id', 'bill_type', 'store_name']);
$table->index(['company_id', 'bill_type', 'occurred_at']);
$table->index(['company_id', 'bill_type', 'bill_item']);
});
}
public function down(): void
{
Schema::dropIfExists('ec_account_bills');
}
};

View File

@ -0,0 +1,168 @@
<?php
namespace Modules\EcommerceFinance\Database\Seeds;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\AccountBillColumns;
class AccountBillsTableSeeder extends Seeder
{
/**
* 为 6 种账户类型生成演示行(淘系支付宝不在内,它有独立表/种子)。
* 运行:php artisan db:seed --class=Modules\\EcommerceFinance\\Database\\Seeds\\AccountBillsTableSeeder
*/
public function run()
{
$companyId = (int) (DB::table('companies')->value('id') ?? 1);
$shops = [
'淘宝-亿家达家居鞋柜电脑桌',
'天猫-亿家达旗舰店',
'天猫-卓禾家具旗舰店',
'拼多多-亿家达家居旗舰店',
'京东-亿家达旗舰店',
'抖音-亿家达旗舰店',
'得物-亿家达旗舰店',
];
$items = ['交易收款', '退款', '提现到银行卡', '服务费', '红包补贴', '佣金扣款', '平台补贴', '以旧换新自补平台过桥'];
$now = now();
$seq = 3000000000000000000;
foreach (AccountBillColumns::types() as $billType) {
$config = AccountBillColumns::for($billType);
DB::table('ec_account_bills')
->where('company_id', $companyId)
->where('bill_type', $billType)
->delete();
$balance = 50000.00;
for ($i = 0; $i < 6; $i++) {
$real = [
'company_id' => $companyId,
'bill_type' => $billType,
'modifier' => 'api',
'created_by' => null,
'created_from' => 'ecommerce-finance',
'created_at' => $now,
'updated_at' => $now,
];
$payload = [];
$income = $i % 2 === 0 ? round(60 + ($i * 37 % 600), 2) : 0.00;
$expense = $i % 2 === 1 ? round(8 + ($i * 13 % 120), 2) : 0.00;
$balance += $income - $expense;
$day = str_pad((string) (1 + ($i % 15)), 2, '0', STR_PAD_LEFT);
$hh = str_pad((string) (8 + ($i % 12)), 2, '0', STR_PAD_LEFT);
$mm = str_pad((string) (($i * 7) % 60), 2, '0', STR_PAD_LEFT);
$occurred = '2026-07-' . $day . ' ' . $hh . ':' . $mm . ':11';
foreach ($config['columns'] as $col) {
[$label, $field, $kind] = [$col['label'], $col['field'], $col['kind']];
$value = $this->valueFor($field, $kind, $label, [
'i' => $i,
'shop' => $shops[($i + crc32($billType)) % count($shops)],
'item' => $items[$i % count($items)],
'income' => $income,
'expense' => $expense,
'balance' => round($balance, 2),
'occurred' => $occurred,
'seq' => $seq,
]);
if (strpos($field, 'payload.') === 0) {
$payload[substr($field, 8)] = $value;
} else {
$real[$field] = $value;
}
}
$real['payload'] = json_encode($payload, JSON_UNESCAPED_UNICODE);
$seq++;
DB::table('ec_account_bills')->insert($this->normalizeRow($real));
}
}
}
private function valueFor($field, $kind, $label, array $c)
{
// 真实列优先按字段定值
switch ($field) {
case 'record_code':
return (string) ($c['seq']);
case 'store_name':
return $c['shop'];
case 'bill_item':
return $c['item'];
case 'occurred_at':
return $c['occurred'];
case 'income':
return $c['income'];
case 'expense':
return $c['expense'];
case 'balance':
return $c['balance'];
case 'amount':
return $c['income'] + $c['expense'];
case 'order_no':
return (string) (1570700000000000000 + $c['i']);
case 'finance_remark':
case 'custom_remark':
return $c['i'] % 3 === 0 ? '财务备注-' . $c['i'] : null;
case 'remark':
return $c['item'] . ' 演示备注';
case 'modifier':
return 'api';
case 'created_at':
case 'updated_at':
return now();
}
// payload 列按 kind 推断
switch ($kind) {
case 'date':
return $c['occurred'];
case 'num':
return (string) (3300000000000000 + $c['i'] + crc32($field));
case 'money':
case 'balance':
return $c['amount'] ?? 0;
case 'money_in':
return $c['income'];
case 'money_out':
return $c['expense'];
case 'tag':
return $c['item'];
default:
return $label . '-' . $c['i'];
}
}
/**
* 保证插入行类型与表结构一致(decimal/null)。
*/
private function normalizeRow(array $real): array
{
$decimals = ['amount', 'income', 'expense', 'balance'];
foreach ($decimals as $k) {
if (isset($real[$k])) {
$real[$k] = (float) $real[$k];
}
}
if (empty($real['finance_remark'])) {
$real['finance_remark'] = null;
}
if (empty($real['custom_remark'])) {
$real['custom_remark'] = null;
}
return $real;
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace Modules\EcommerceFinance\Http\Controllers;
use App\Abstracts\Http\Controller;
use Illuminate\Http\Request;
use Modules\EcommerceFinance\AccountBillColumns;
use Modules\EcommerceFinance\Models\AccountBill;
class AccountBills extends Controller
{
/**
* 通用账单列表(店铺收款账户账单 › 各账户类型 tab)。
* 经 admin 路由组(web 会话)暴露,前端带 CSRF 调用。
* 按 bill_type 过滤,返回分页数据 + 汇总 + 该类型的列配置。
*/
public function index(Request $request)
{
$billType = (string) $request->get('bill_type', '');
if (! AccountBillColumns::has($billType)) {
abort(404, '未知的账户类型。');
}
$query = AccountBill::query()
->where('company_id', company_id())
->where('bill_type', $billType);
if ($store = $request->get('store_name')) {
$query->where('store_name', $store);
}
if ($item = $request->get('bill_item')) {
$query->where('bill_item', $item);
}
if ($start = $request->get('start')) {
$query->where('occurred_at', '>=', $start);
}
if ($end = $request->get('end')) {
$query->where('occurred_at', '<=', $end . ' 23:59:59');
}
if ($keyword = $request->get('keyword')) {
$query->where(function ($q) use ($keyword) {
$like = '%' . $keyword . '%';
$q->where('record_code', 'like', $like)
->orWhere('order_no', 'like', $like)
->orWhere('remark', 'like', $like)
->orWhere('bill_item', 'like', $like);
});
}
$sortable = ['occurred_at', 'income', 'expense', 'balance', 'record_code'];
$sort = in_array($request->get('sort'), $sortable, true) ? $request->get('sort') : 'occurred_at';
$dir = $request->get('dir') === 'asc' ? 'asc' : 'desc';
$query->orderBy($sort, $dir);
$perPage = min(max((int) $request->get('limit', 10), 1), 100);
$rows = $query->paginate($perPage);
// 汇总(分页前)
$summaryRow = (clone $query)->selectRaw(
'COALESCE(SUM(income), 0) as income, ' .
'COALESCE(SUM(expense), 0) as expense, ' .
'COUNT(*) as count'
)->first();
$latestBalance = (clone $query)
->orderByDesc('occurred_at')
->orderByDesc('id')
->value('balance');
$data = $rows->toArray();
$data['summary'] = [
'income' => (float) ($summaryRow->income ?? 0),
'expense' => (float) ($summaryRow->expense ?? 0),
'count' => (int) ($summaryRow->count ?? 0),
'balance' => $latestBalance !== null ? (float) $latestBalance : 0,
];
$data['columns'] = AccountBillColumns::for($billType);
return response()->json($data);
}
public function show(AccountBill $bill)
{
return response()->json($bill);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace Modules\EcommerceFinance\Models;
use App\Abstracts\Model;
class AccountBill extends Model
{
use \Illuminate\Database\Eloquent\SoftDeletes;
protected $table = 'ec_account_bills';
protected $fillable = [
'company_id',
'bill_type',
'record_code',
'store_name',
'finance_remark',
'order_no',
'amount',
'custom_remark',
'bill_item',
'occurred_at',
'income',
'expense',
'balance',
'remark',
'modifier',
'payload',
'created_by',
'created_from',
];
protected $casts = [
'occurred_at' => 'datetime',
'amount' => 'decimal:6',
'income' => 'decimal:6',
'expense' => 'decimal:6',
'balance' => 'decimal:6',
'payload' => 'array',
];
}

View File

@ -5,7 +5,11 @@ use Illuminate\Support\Facades\Route;
Route::admin('ecommerce-finance', function () {
Route::view('/', 'ecommerce-finance::index')->name('index');
// 店铺收款账户账单 › 淘系支付宝账户
// 店铺收款账户账单 › 淘系支付宝账户(独立表 ec_alipay_bills)
Route::get('alipay-bills', 'AlipayBills@index')->name('alipay-bills.index');
Route::get('alipay-bills/{bill}', 'AlipayBills@show')->name('alipay-bills.show');
// 店铺收款账户账单 › 其它账户类型(通用表 ec_account_bills,按 bill_type 区分)
Route::get('account-bills', 'AccountBills@index')->name('account-bills.index');
Route::get('account-bills/{bill}', 'AccountBills@show')->name('account-bills.show');
});

View File

@ -0,0 +1,319 @@
<template>
<div class="account-page">
<!-- 汇总卡片 -->
<div class="ab-stats">
<div class="ab-stat">
<div class="ab-stat-k">收入合计</div>
<div class="ab-stat-v income">{{ money(summary.income) }}</div>
<div class="ab-stat-s">{{ summary.count }} 笔交易</div>
</div>
<div class="ab-stat">
<div class="ab-stat-k">支出合计</div>
<div class="ab-stat-v expense">{{ money(summary.expense) }}</div>
<div class="ab-stat-s">按当前筛选</div>
</div>
<div class="ab-stat">
<div class="ab-stat-k">期末余额(最新)</div>
<div class="ab-stat-v">{{ money(summary.balance) }}</div>
<div class="ab-stat-s">最新一条记录</div>
</div>
<div class="ab-stat">
<div class="ab-stat-k">交易笔数</div>
<div class="ab-stat-v">{{ summary.count }}</div>
<div class="ab-stat-s">当前筛选结果</div>
</div>
</div>
<!-- 筛选栏 -->
<div class="ab-filter">
<div class="ab-field">
<label>店铺</label>
<input v-model.trim="filters.store_name" placeholder="店铺名称" />
</div>
<div class="ab-field">
<label>账单项目</label>
<input v-model.trim="filters.bill_item" placeholder="账单项目" />
</div>
<div class="ab-field ab-field-date">
<label>时间</label>
<div class="ab-date">
<input type="date" v-model="filters.start" /> <span>~</span>
<input type="date" v-model="filters.end" />
</div>
</div>
<div class="ab-field ab-field-kw">
<label>关键词(记录编码 / 订单 / 备注)</label>
<input v-model.trim="filters.keyword" placeholder="输入关键词" />
</div>
<div class="ab-actions">
<button class="ab-btn ab-btn-primary" @click="applyFilters">查询</button>
<button class="ab-btn" @click="resetFilters">重置</button>
</div>
</div>
<!-- 数据表(列由后端配置动态渲染) -->
<div class="ab-panel">
<div class="ab-phead">
<div class="ab-title">{{ columnsTitle }}</div>
<div class="ab-count" v-if="!loading">{{ total }} 条</div>
</div>
<div class="ab-scroll">
<table class="ab-table" v-if="rows.length && columns.length">
<thead>
<tr>
<th v-for="(c, i) in columns" :key="i" :class="{ 'ab-num': isNumKind(c.kind) }"
@click="maybeSort(c)">
{{ c.label }}
<span class="ab-sort" v-if="sortableField(c.field)">⇅</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="r in rows" :key="r.id">
<td v-for="(c, i) in columns" :key="i" :class="cellClass(c)">
<span v-if="c.kind === 'tag'" class="ab-tag">{{ cellText(r, c) || '—' }}</span>
<template v-else>{{ cellDisplay(r, c) }}</template>
</td>
</tr>
</tbody>
</table>
<div class="ab-empty" v-else-if="!loading">没有符合条件的账单记录</div>
<div class="ab-empty" v-else>加载中…</div>
</div>
<!-- 分页 -->
<div class="ab-pager" v-if="rows.length">
<div class="ab-page-info">第 {{ rangeStart }} - {{ rangeEnd }} 条 / 共 {{ total }} 条</div>
<div class="ab-page-right">
<span>每页</span>
<select v-model.number="perPage" @change="changePage(1)">
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
</select>
<button class="ab-page-btn" :disabled="currentPage <= 1" @click="changePage(1)">«</button>
<button class="ab-page-btn" :disabled="currentPage <= 1" @click="changePage(currentPage - 1)">‹</button>
<span class="ab-page-no">{{ currentPage }} / {{ lastPage }}</span>
<button class="ab-page-btn" :disabled="currentPage >= lastPage" @click="changePage(currentPage + 1)">›</button>
<button class="ab-page-btn" :disabled="currentPage >= lastPage" @click="changePage(lastPage)">»</button>
</div>
</div>
</div>
</div>
</template>
<script>
const SORTABLE = ['occurred_at', 'income', 'expense', 'balance', 'record_code'];
export default {
name: 'AccountBills',
props: {
apiBase: { type: String, required: true },
billType: { type: String, required: true },
},
data() {
return {
loading: false,
rows: [],
columns: [],
columnsTitle: '',
summary: { income: 0, expense: 0, balance: 0, count: 0 },
total: 0,
currentPage: 1,
lastPage: 1,
perPage: 10,
filters: { store_name: '', bill_item: '', start: '', end: '', keyword: '' },
sort: { key: 'occurred_at', dir: 'desc' },
};
},
computed: {
rangeStart() {
return this.total === 0 ? 0 : (this.currentPage - 1) * this.perPage + 1;
},
rangeEnd() {
return Math.min(this.currentPage * this.perPage, this.total);
},
},
mounted() {
this.fetch();
},
methods: {
async fetch() {
this.loading = true;
const params = {
bill_type: this.billType,
...this.filters,
sort: this.sort.key,
dir: this.sort.dir,
limit: this.perPage,
page: this.currentPage,
};
try {
const res = await window.axios.get(this.apiBase, { params });
const body = res.data || {};
this.rows = body.data || [];
this.total = body.total || 0;
this.currentPage = body.current_page || 1;
this.lastPage = body.last_page || 1;
this.perPage = body.per_page || this.perPage;
this.summary = body.summary || { income: 0, expense: 0, balance: 0, count: 0 };
if (body.columns) {
this.columns = body.columns.columns || body.columns || [];
this.columnsTitle = body.columns.title || '';
}
} catch (e) {
console.error('加载账单失败', e);
this.rows = [];
this.total = 0;
this.summary = { income: 0, expense: 0, balance: 0, count: 0 };
} finally {
this.loading = false;
}
},
applyFilters() {
this.currentPage = 1;
this.fetch();
},
resetFilters() {
this.filters = { store_name: '', bill_item: '', start: '', end: '', keyword: '' };
this.currentPage = 1;
this.fetch();
},
changePage(p) {
this.currentPage = p;
this.fetch();
},
maybeSort(c) {
if (!this.sortableField(c.field)) return;
if (this.sort.key === c.field) {
this.sort.dir = this.sort.dir === 'asc' ? 'desc' : 'asc';
} else {
this.sort.key = c.field;
this.sort.dir = 'desc';
}
this.currentPage = 1;
this.fetch();
},
sortableField(field) {
return SORTABLE.indexOf(field) !== -1;
},
getValue(row, field) {
if (!field) return '';
if (field.indexOf('payload.') === 0) {
const key = field.slice(8);
const p = row.payload || {};
return p[key] === null || p[key] === undefined ? '' : p[key];
}
return row[field] === null || row[field] === undefined ? '' : row[field];
},
cellText(row, c) {
return this.getValue(row, c.field);
},
cellDisplay(row, c) {
const v = this.getValue(row, c.field);
if (v === '' || v === null || v === undefined) return '—';
switch (c.kind) {
case 'money_in':
return '+' + this.money(v);
case 'money_out':
return '−' + this.money(v);
case 'balance':
case 'money':
return this.money(v);
default:
return v;
}
},
cellClass(c) {
const cls = { 'ab-num': this.isNumKind(c.kind) };
if (c.kind === 'money_in') cls['ab-in'] = true;
if (c.kind === 'money_out') cls['ab-out'] = true;
if (c.kind === 'num') cls['ab-mono'] = true;
return cls;
},
isNumKind(kind) {
return kind === 'money' || kind === 'money_in' || kind === 'money_out' || kind === 'balance' || kind === 'num';
},
money(n) {
const v = Number(n || 0);
return '¥' + v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
},
},
};
</script>
<style scoped>
.account-page { padding: 1.25rem 1.5rem 1.5rem; }
.ab-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.875rem; margin-bottom: 0.875rem; }
.ab-stat { background: #fff; border: 1px solid #e6e9f0; border-radius: 0.75rem; padding: 1rem 1.125rem; box-shadow: 0 1px 2px rgba(16,24,40,.04), 0 1px 3px rgba(16,24,40,.06); }
.ab-stat-k { font-size: 0.75rem; color: #6b7280; }
.ab-stat-v { font-size: 1.375rem; font-weight: 700; margin-top: 0.5rem; letter-spacing: .3px; }
.ab-stat-s { font-size: 0.75rem; color: #9aa3b2; margin-top: 0.25rem; }
.ab-stat-v.income { color: #f5222d; }
.ab-stat-v.expense { color: #16a34a; }
.ab-filter { display: flex; flex-wrap: wrap; gap: 0.875rem; background: #fff; border: 1px solid #e6e9f0; border-radius: 0.75rem; padding: 1rem 1.125rem; margin-bottom: 0.875rem; }
.ab-field { display: flex; flex-direction: column; gap: 0.3125rem; }
.ab-field label { font-size: 0.75rem; color: #6b7280; font-weight: 500; }
.ab-field input { height: 2.125rem; min-width: 11.25rem; padding: 0 0.625rem; border: 1px solid #e6e9f0; border-radius: 0.5rem; background: #fff; font-size: 0.8125rem; color: #1f2937; outline: none; }
.ab-field input:focus { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79,70,229,.12); }
.ab-field-kw { flex: 1; min-width: 12.5rem; }
.ab-field-kw input { width: 100%; }
.ab-date { display: flex; align-items: center; gap: 0.375rem; }
.ab-date input { min-width: 8.75rem; }
.ab-actions { display: flex; gap: 0.625rem; align-items: flex-end; margin-left: auto; }
.ab-btn { height: 2.125rem; padding: 0 0.875rem; border-radius: 0.5rem; border: 1px solid #e6e9f0; background: #fff; color: #1f2937; cursor: pointer; font-size: 0.8125rem; font-weight: 500; }
.ab-btn:hover { border-color: #c7cdda; }
.ab-btn-primary { background: #4f46e5; border-color: #4f46e5; color: #fff; }
.ab-btn-primary:hover { background: #4338ca; }
.ab-panel { background: #fff; border: 1px solid #e6e9f0; border-radius: 0.75rem; box-shadow: 0 1px 2px rgba(16,24,40,.04), 0 1px 3px rgba(16,24,40,.06); overflow: hidden; }
.ab-phead { display: flex; align-items: center; gap: 0.625rem; padding: 0.875rem 1rem; border-bottom: 1px solid #e6e9f0; }
.ab-title { font-weight: 600; font-size: 0.875rem; }
.ab-count { color: #9aa3b2; font-size: 0.75rem; }
.ab-scroll { overflow-x: auto; }
.ab-table { border-collapse: collapse; width: 100%; font-size: 0.78125rem; }
.ab-table thead th { background: #fafbfd; color: #1f2937; font-weight: 600; text-align: left; border-bottom: 1px solid #e6e9f0; padding: 0.5625rem 0.625rem; white-space: nowrap; }
.ab-table thead th.ab-num { text-align: right; }
.ab-sort { color: #9aa3b2; margin-left: 0.1875rem; }
.ab-table tbody td { padding: 0.5625rem 0.625rem; border-bottom: 1px solid #f1f3f7; white-space: nowrap; color: #1f2937; }
.ab-table tbody tr:hover { background: #f7f9ff; }
.ab-num { text-align: right; font-variant-numeric: tabular-nums; }
.ab-mono { font-family: ui-monospace, Menlo, Consolas, monospace; color: #6b7280; font-size: 0.71875rem; }
.ab-in { color: #f5222d; font-weight: 600; }
.ab-out { color: #16a34a; font-weight: 600; }
.ab-tag { display: inline-block; padding: 0.0625rem 0.5rem; border-radius: 1rem; font-size: 0.71875rem; font-weight: 500; background: #eef2ff; color: #4f46e5; }
.ab-empty { padding: 3rem; text-align: center; color: #9aa3b2; }
.ab-pager { display: flex; align-items: center; gap: 0.625rem; padding: 0.875rem 1rem; border-top: 1px solid #e6e9f0; flex-wrap: wrap; }
.ab-page-info { color: #6b7280; font-size: 0.78125rem; }
.ab-page-right { margin-left: auto; display: flex; align-items: center; gap: 0.375rem; font-size: 0.78125rem; }
.ab-page-right select { height: 1.875rem; border: 1px solid #e6e9f0; border-radius: 0.4375rem; padding: 0 0.375rem; }
.ab-page-btn { height: 1.875rem; min-width: 1.875rem; padding: 0 0.5rem; border: 1px solid #e6e9f0; background: #fff; border-radius: 0.4375rem; cursor: pointer; font-size: 0.78125rem; color: #1f2937; }
.ab-page-btn:hover:not(:disabled) { border-color: #4f46e5; color: #4f46e5; }
.ab-page-btn:disabled { opacity: .45; cursor: not-allowed; }
.ab-page-no { padding: 0 0.375rem; }
</style>

View File

@ -1,48 +1,74 @@
/**
* 业务工作台(店铺管理 › 各子功能)统一前端入口。
* 业务工作台(账单流水数据 › 各子功能)统一前端入口。
* 对应 Blade:resources/views/business/pages/show.blade.php
* 从当前 URL(/{company_id}/workspace/{section}/{page}/{tab})取出
* company_id 与 page slug,挂载 Vue 并按 slug 渲染对应组件。
* 路由:/{company_id}/workspace/{section}/{page}/{tab}
*
* 淘系支付宝账户(store-receiving-bill › taobao-alipay)在此实现;
* 京东流水(jd-transactions)等后续页面复用同一框架,按 slug 增加分支即可。
* 店铺收款账户账单(store-receiving-bills)下各 tab:
* - taobao-alipay → 独立实现 TaobaoAlipayBills(表 ec_alipay_bills)
* - taobao-wechat / pinduoduo / jd-wallet / jd-transactions / douyin / dewu
* → 通用实现 AccountBills(表 ec_account_bills,按 bill_type 区分,列配置由后端下发)
*/
require('./../../bootstrap');
import Vue from 'vue';
import TaobaoAlipayBills from './../../components/business/TaobaoAlipayBills.vue';
import AccountBills from './../../components/business/AccountBills.vue';
const GENERIC_TABS = ['taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'douyin', 'dewu'];
function pageContext() {
const parts = (window.location.pathname || '').split('/').filter(Boolean);
const idx = parts.indexOf('workspace');
if (idx === -1) {
return { companyId: '', page: '' };
return { companyId: '', section: '', page: '', tab: '' };
}
const companyId = idx > 0 ? parts[idx - 1] : '';
const page = parts[idx + 1] || '';
return { companyId, page };
return {
companyId: idx > 0 ? parts[idx - 1] : '',
section: parts[idx + 1] || '',
page: parts[idx + 2] || '',
tab: parts[idx + 3] || '',
};
}
const { companyId, page } = pageContext();
const { companyId, page, tab } = pageContext();
const apiBase = companyId
const alipayBase = companyId
? `/${companyId}/ecommerce-finance/alipay-bills`
: '/ecommerce-finance/alipay-bills';
const accountBase = companyId
? `/${companyId}/ecommerce-finance/account-bills`
: '/ecommerce-finance/account-bills';
function resolveComponent() {
// 店铺收款账户账单 › 淘系支付宝账户(独立实现)
if (page === 'store-receiving-bills' && tab === 'taobao-alipay') {
return { Component: TaobaoAlipayBills, props: { apiBase: alipayBase } };
}
// 店铺收款账户账单 › 其它账户类型(通用实现)
if (page === 'store-receiving-bills' && GENERIC_TABS.indexOf(tab) !== -1) {
return { Component: AccountBills, props: { apiBase: accountBase, billType: tab } };
}
return null;
}
const mount = document.querySelector('.business-blank-page');
if (mount) {
const resolved = resolveComponent();
/* eslint-disable no-new */
new Vue({
el: mount,
render(h) {
if (page === 'taobao-alipay') {
return h(TaobaoAlipayBills, { props: { apiBase } });
if (resolved) {
return h(resolved.Component, { props: resolved.props });
}
return h('div', { class: 'business-page-empty' }, '该页面尚未实现');

View File

@ -99,7 +99,7 @@
<section class="business-blank-page" aria-label="{{ $activeTab['title'] ?? ($page['title'] ?? $section['title']) }}"></section>
</div>
@if (in_array($page['slug'] ?? null, ['taobao-alipay', 'taobao-wechat', 'jd-wallet', 'jd-transactions']))
@if (($page['slug'] ?? null) === 'store-receiving-bills')
<script src="{{ asset('js/business/pages.min.js') }}"></script>
@endif
</x-slot>