统一财务账单表格排版与交互 (#139)

This commit is contained in:
chenxi 2026-09-09 13:21:38 +08:00
parent 284ba38337
commit 01a2e6bfa8
6 changed files with 492 additions and 774 deletions

View File

@ -25,6 +25,17 @@ class AccountBills extends Controller
abort(404, '未知的账户类型。');
}
$request->validate([
'store_name' => ['nullable', 'string', 'max:255'],
'bill_item' => ['nullable', 'string', 'max:255'],
'start' => ['nullable', 'date_format:Y-m-d'],
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
'keyword' => ['nullable', 'string', 'max:255'],
'export' => ['nullable', 'boolean'],
'columns' => ['nullable', 'array', 'max:40'],
'columns.*' => ['string', 'max:128'],
]);
$query = AccountBill::query()
->where('company_id', company_id())
->where('bill_type', $billType);
@ -60,6 +71,10 @@ class AccountBills extends Controller
$dir = $request->get('dir') === 'asc' ? 'asc' : 'desc';
$query->orderBy($sort, $dir);
if ($request->boolean('export')) {
return $this->export($request, $query, $billType);
}
$perPage = min(max((int) $request->get('limit', 10), 1), 100);
$rows = $query->paginate($perPage);
@ -87,6 +102,52 @@ class AccountBills extends Controller
return response()->json($data);
}
private function export(Request $request, $query, string $billType)
{
$definition = AccountBillColumns::for($billType);
$available = collect($definition['columns'] ?? [])->keyBy('field');
$requested = $request->input('columns', []);
$columns = $requested === []
? $available->values()
: collect($requested)->unique()->map(fn (string $field) => $available->get($field))->filter()->values();
abort_if($columns->isEmpty(), 422, '至少选择一个可导出的字段。');
return response()->streamDownload(function () use ($query, $columns): void {
$csv = fopen('php://output', 'w');
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, $columns->pluck('label')->all());
foreach ($query->cursor() as $row) {
fputcsv($csv, $columns->map(function (array $column) use ($row): string {
$value = $this->columnValue($row, $column['field']);
return $this->csvValue($value, $column['kind']);
})->all());
}
fclose($csv);
}, $billType . '_account_bills.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
private function columnValue(AccountBill $row, string $field): mixed
{
if (str_starts_with($field, 'payload.')) {
return data_get($row->payload ?? [], substr($field, 8), '');
}
return $row->getAttribute($field) ?? '';
}
private function csvValue(mixed $value, string $kind): string
{
$text = (string) ($value ?? '');
if ($kind === 'num' && preg_match('/^\d{12,}$/', $text)) {
return "\t" . $text;
}
return preg_match('/^[=+\-@]/', $text) ? "'" . $text : $text;
}
public function show(AccountBill $bill)
{
$this->authorizeStoreReceivingBills();

View File

@ -11,6 +11,34 @@ use RuntimeException;
class AlipayBills extends Controller
{
private const EXPORT_COLUMNS = [
'record_code' => '记录编码',
'store_name' => '原始店铺',
'custom_remark' => '自定义备注',
'bill_item' => '账单项目',
'alipay_account' => '平台渠道',
'account_order_no' => '账务订单号',
'business_serial_no' => '业务流水号',
'merchant_order_no' => '商户订单号',
'business_base_order_no' => '业务基础订单号',
'goods_name' => '商品编码',
'counterparty_account' => '对方账号',
'occurred_at' => '发生时间',
'income' => '收入',
'expense' => '支出',
'balance' => '收支净额',
'business_type' => '业务类型',
'remark' => '备注',
'online_order_no' => '线上单号',
'online_real_store' => '线上单号对应实际店铺',
'business_description' => '业务描述',
'business_bill_source' => '业务账单来源',
'business_order_no' => '业务订单号',
'created_at' => '创建时间',
'updated_at' => '修改时间',
'modifier' => '修改人',
];
/**
* 淘系支付宝账户账单列表(店铺收款账户账单 › 淘系支付宝账户)。
* 经由 admin 路由组(web 会话)暴露,前端带 CSRF 调用即可。
@ -68,8 +96,13 @@ class AlipayBills extends Controller
'start' => ['nullable', 'date_format:Y-m-d'],
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
'keyword' => ['nullable', 'string', 'max:255'],
'columns' => ['nullable', 'array', 'max:25'],
'columns.*' => ['string', 'max:64'],
]);
$columns = $this->requestedExportColumns($filters['columns'] ?? []);
unset($filters['columns']);
try {
$rows = $repository->exportRows(array_map(static fn (mixed $value): string => trim((string) $value), $filters));
} catch (RuntimeException $exception) {
@ -80,17 +113,49 @@ class AlipayBills extends Controller
return response()->json(['message' => '淘系支付宝账单共享业务镜像正在首次同步,完成后将自动显示。'], 503);
}
return response()->streamDownload(function () use ($rows): void {
return response()->streamDownload(function () use ($rows, $columns): void {
$csv = fopen('php://output', 'w');
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, ['记录编码', '原始店铺', '平台渠道', '账单项目', '账务订单号', '业务流水号', '商户订单号', '业务基础订单号', '商品编码', '发生时间', '收入(元)', '支出(元)', '收支净额(元)', '收支类型', '线上单号', '业务描述', '业务账单来源', '创建时间', '修改时间']);
fputcsv($csv, array_values($columns));
foreach ($rows as $row) {
fputcsv($csv, [$row['record_code'], $row['store_name'], $row['alipay_account'], $row['bill_item'], $row['account_order_no'], $row['business_serial_no'], $row['merchant_order_no'], $row['business_base_order_no'], $row['goods_name'], $row['occurred_at'], $row['income'], $row['expense'], $row['balance'], $row['business_type'], $row['online_order_no'], $row['business_description'], $row['business_bill_source'], $row['created_at'], $row['updated_at']]);
fputcsv($csv, array_map(
fn (string $field): string => $this->csvValue($row[$field] ?? '', $field),
array_keys($columns),
));
}
fclose($csv);
}, 'taobao_alipay_bills.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/** @return array<string,string> */
private function requestedExportColumns(array $requested): array
{
if ($requested === []) {
return self::EXPORT_COLUMNS;
}
$columns = [];
foreach ($requested as $field) {
if (isset(self::EXPORT_COLUMNS[$field])) {
$columns[$field] = self::EXPORT_COLUMNS[$field];
}
}
abort_if($columns === [], 422, '至少选择一个可导出的字段。');
return $columns;
}
private function csvValue(mixed $value, string $field): string
{
$text = (string) ($value ?? '');
if (preg_match('/(?:_no|_code|serial|goods_name)$/', $field) && preg_match('/^\d{12,}$/', $text)) {
return "\t" . $text;
}
return preg_match('/^[=+\-@]/', $text) ? "'" . $text : $text;
}
public function show(AlipayBill $bill)
{
$this->authorizeStoreReceivingBills();

View File

@ -1,342 +1,72 @@
<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 class="table-workspace">
<div class="tw-stats">
<div v-for="stat in stats" :key="stat.label" class="tw-stat"><div class="tw-label">{{ stat.label }}</div><div class="tw-value" :class="stat.className">{{ stat.value }}</div><div class="tw-note">{{ stat.note }}</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" />
<form class="tw-filter" @submit.prevent="applyFilters">
<label>店铺<input v-model.trim="filters.store_name" placeholder="店铺名称" /></label>
<label>账单项目<input v-model.trim="filters.bill_item" placeholder="账单项目" /></label>
<label class="tw-date">发生时间<span><input v-model="filters.start" type="date" aria-label="开始日期" /><b>至</b><input v-model="filters.end" type="date" aria-label="结束日期" /></span></label>
<label class="tw-keyword">关键词(记录编码 / 订单 / 备注)<input v-model.trim="filters.keyword" placeholder="输入关键词" /></label>
<div class="tw-actions"><button class="tw-button tw-primary" type="submit">查询</button><button class="tw-button" type="button" @click="resetFilters">重置</button></div>
</form>
<section class="tw-panel" :aria-label="`${columnsTitle || '账户'}账单明细`">
<div v-if="loadError" class="tw-alert" role="alert"><span>账单加载失败,请重试</span><button type="button" @click="fetch">重新加载</button></div>
<div class="tw-toolbar">
<div><strong>{{ sourceGroups.length ? '账单明细' : columnsTitle }}</strong><span v-if="!loading" class="tw-count">{{ total }} 条</span></div>
<div class="tw-tools" v-if="sourceColumns.length">
<select v-model="exportMode" title="选择导出范围" aria-label="导出范围"><option value="page-visible">当前页 · 当前显示列</option><option value="filtered-visible">全部筛选结果 · 当前显示列</option><option value="filtered-all">全部筛选结果 · 全部字段</option></select>
<button type="button" class="tw-button" @click="exportRows"><span class="material-icons-outlined" aria-hidden="true">download</span>导出</button>
<table-column-settings :columns="sourceColumns" :user-key="userKey" page-key="store-receiving-bills" :tab-key="billType" table-key="bill-details" @change="configuredColumns = $event" />
</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">{{ columnGroups.length ? '账单明细' : columnsTitle }}</div>
<div class="ab-count" v-if="!loading">{{ total }} 条</div>
</div>
<div class="ab-scroll">
<table class="ab-table" :class="{ 'ab-table-grouped': columnGroups.length }" v-if="!loading && !loadError && columns.length">
<div ref="scroll" class="tw-scroll">
<table v-if="!loading && !loadError && configuredColumns.length" class="tw-table">
<thead>
<tr class="ab-group" v-if="columnGroups.length">
<th v-for="(group, i) in columnGroups" :key="i" :colspan="group.span" scope="colgroup">
{{ group.label }}
</th>
</tr>
<tr class="ab-col">
<th v-for="(c, i) in columns" :key="i" :class="{ 'ab-num': isNumKind(c.kind) }" scope="col"
@click="maybeSort(c)">
{{ c.label }}
<span class="ab-sort" v-if="sortableField(c.field)">⇅</span>
</th>
</tr>
<tr v-if="visibleGroups.length" class="tw-groups"><th v-for="(group,index) in visibleGroups" :key="`${group.label}-${index}`" :colspan="group.span" scope="colgroup">{{ group.label }}</th></tr>
<tr class="tw-columns" :class="{ 'is-single': !visibleGroups.length }"><th v-for="column in visibleColumns" :key="column.key" :class="{ 'is-number': isNumber(column.kind), 'is-sortable': sortableField(column.field) }" scope="col" @click="maybeSort(column)">{{ column.label }} <span v-if="sortableField(column.field)" class="tw-sort" aria-hidden="true">{{ sortMark(column.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>
<tr v-if="!rows.length" class="ab-empty-row">
<td :colspan="columns.length" class="ab-empty">没有符合条件的账单记录</td>
</tr>
<tr v-for="row in rows" :key="row.id"><td v-for="column in visibleColumns" :key="column.key" :class="cellClass(column)"><span v-if="column.kind === 'tag'" class="tw-tag">{{ displayValue(row,column) }}</span><template v-else>{{ displayValue(row,column) }}</template></td></tr>
<tr v-if="!rows.length"><td :colspan="visibleColumns.length" class="tw-empty">没有符合条件的账单记录</td></tr>
</tbody>
</table>
<div v-else-if="loading" class="ab-empty">加载中…</div>
<div v-else-if="loadError" class="ab-empty ab-error">
<div>账单加载失败,请重试</div>
<button class="ab-btn" @click="fetch">重新加载</button>
</div>
<div v-else class="ab-empty">暂无账单字段</div>
<div v-else-if="loading" class="tw-empty" role="status">加载中…</div><div v-else-if="!loadError" class="tw-empty">暂无账单字段</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 v-if="rows.length" class="tw-pager"><div>第 {{ rangeStart }} - {{ rangeEnd }} 条 / 共 {{ total }} 条</div><div class="tw-page-actions"><label>每页 <select v-model.number="perPage" @change="changePage(1)"><option :value="10">10</option><option :value="20">20</option><option :value="50">50</option></select></label><button type="button" :disabled="currentPage<=1" title="第一页" @click="changePage(1)">«</button><button type="button" :disabled="currentPage<=1" title="上一页" @click="changePage(currentPage-1)">‹</button><span>{{ currentPage }} / {{ lastPage }}</span><button type="button" :disabled="currentPage>=lastPage" title="下一页" @click="changePage(currentPage+1)">›</button><button type="button" :disabled="currentPage>=lastPage" title="最后一页" @click="changePage(lastPage)">»</button></div></div>
</section>
</div>
</template>
<script>
const SORTABLE = ['occurred_at', 'income', 'expense', 'balance', 'record_code'];
import TableColumnSettings from './TableColumnSettings.vue';
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,
loadError: false,
rows: [],
columns: [],
columnsTitle: '',
columnGroups: [],
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;
this.loadError = false;
const params = {
...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 || '';
this.columnGroups = body.columns.groups || [];
}
} catch (e) {
console.error('加载账单失败', e);
this.rows = [];
this.columns = [];
this.columnsTitle = '';
this.columnGroups = [];
this.total = 0;
this.summary = { income: 0, expense: 0, balance: 0, count: 0 };
this.loadError = true;
} 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 });
},
name:'AccountBills', components:{ TableColumnSettings }, props:{ apiBase:{type:String,required:true}, billType:{type:String,required:true} },
data(){return{loading:false,loadError:false,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:{store_name:'',bill_item:'',start:'',end:'',keyword:''},sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible'};},
computed:{
userKey(){const mount=document.querySelector('.business-blank-page');return mount&&mount.dataset.tablePreferencesUser?mount.dataset.tablePreferencesUser:'anonymous';},
visibleColumns(){return this.configuredColumns.filter(column=>column.visible);},
visibleGroups(){return this.visibleColumns.reduce((groups,column)=>{if(!column.group)return groups;const last=groups[groups.length-1];if(last&&last.label===column.group)last.span+=1;else groups.push({label:column.group,span:1});return groups;},[]);},
stats(){return[{label:'收入合计',value:this.money(this.summary.income),note:`${this.summary.count} 笔交易`,className:'is-income'},{label:'支出合计',value:this.money(this.summary.expense),note:'按当前筛选',className:'is-expense'},{label:'期末余额(最新)',value:this.money(this.summary.balance),note:'最新一条记录',className:''},{label:'交易笔数',value:this.summary.count,note:'当前筛选结果',className:''}];},
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;this.loadError=false;try{const response=await window.axios.get(this.apiBase,{params:{...this.filters,sort:this.sort.key,dir:this.sort.dir,limit:this.perPage,page:this.currentPage}});const body=response.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.columnsTitle=body.columns.title||'';this.sourceGroups=body.columns.groups||[];this.sourceColumns=this.attachGroups(body.columns.columns||body.columns||[],this.sourceGroups);}}catch(error){this.rows=[];this.total=0;this.summary={income:0,expense:0,balance:0,count:0};this.loadError=true;}finally{this.loading=false;}},
attachGroups(columns,groups){let index=0;return columns.map((column,columnIndex)=>{while(groups[index]&&columnIndex>=groups.slice(0,index+1).reduce((sum,group)=>sum+Number(group.span||0),0))index+=1;return{...column,key:column.field||`column-${columnIndex}`,group:groups[index]?groups[index].label:''};});},
applyFilters(){this.currentPage=1;this.fetch();},resetFilters(){this.filters={store_name:'',bill_item:'',start:'',end:'',keyword:''};this.applyFilters();},changePage(page){this.currentPage=page;this.fetch();this.$nextTick(()=>{if(this.$refs.scroll)this.$refs.scroll.scrollTop=0;});},
maybeSort(column){if(!this.sortableField(column.field))return;this.sort.dir=this.sort.key===column.field&&this.sort.dir==='desc'?'asc':'desc';this.sort.key=column.field;this.applyFilters();},sortableField(field){return SORTABLE.indexOf(field)!==-1;},sortMark(field){return this.sort.key===field?(this.sort.dir==='asc'?'↑':'↓'):'↕';},
getValue(row,field){if(!field)return'';if(field.indexOf('payload.')===0){const payload=row.payload||{};const value=payload[field.slice(8)];return value===null||value===undefined?'':value;}const value=row[field];return value===null||value===undefined?'':value;},rawValue(row,column){return this.getValue(row,column.field);},
displayValue(row,column){const value=this.rawValue(row,column);if(value==='')return'—';if(column.kind==='money_in')return Number(value)>0?`+${this.money(value)}`:'—';if(column.kind==='money_out')return Number(value)>0?`-${this.money(value)}`:'—';if(['balance','money'].indexOf(column.kind)!==-1)return this.money(value);return value;},cellClass(column){return{'is-number':this.isNumber(column.kind),'is-id':column.kind==='num','is-income':column.kind==='money_in','is-expense':column.kind==='money_out'};},isNumber(kind){return['money','money_in','money_out','balance','num'].indexOf(kind)!==-1;},money(value){return`¥${Number(value||0).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})}`;},
exportRows(){if(this.exportMode==='page-visible'){this.downloadCurrentPage();return;}const params=new URLSearchParams(this.filters);params.set('export','1');params.set('sort',this.sort.key);params.set('dir',this.sort.dir);if(this.exportMode==='filtered-visible')this.visibleColumns.forEach(column=>params.append('columns[]',column.field));window.location.assign(`${this.apiBase}?${params.toString()}`);},
downloadCurrentPage(){const csvRows=[this.visibleColumns.map(column=>column.label),...this.rows.map(row=>this.visibleColumns.map(column=>this.rawValue(row,column)))];const csv=csvRows.map(values=>values.map(this.csvCell).join(',')).join('\r\n');const link=document.createElement('a');link.href=URL.createObjectURL(new Blob(['\ufeff',csv],{type:'text/csv;charset=utf-8'}));link.download=`${this.billType}_account_bills_page_${this.currentPage}.csv`;link.click();URL.revokeObjectURL(link.href);},csvCell(value){const text=String(value===null||value===undefined?'':value);const safe=/^[=+\-@]/.test(text)?`'${text}`:text;return`"${safe.replace(/"/g,'""')}"`;}
}
};
</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: var(--vip-content-accent); box-shadow: 0 0 0 3px rgba(185,149,79,.16); }
.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: var(--vip-content-accent); border-color: var(--vip-content-accent); color: #fff; }
.ab-btn-primary:hover { background: var(--vip-content-accent-dark); }
.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-grouped { min-width: 2200px; }
.ab-table thead .ab-group th { background: #f0f3f9; color: #6b7280; font-size: 0.75rem; font-weight: 600; text-align: center; border-bottom: 1px solid #dfe4ec; padding: 0.625rem; white-space: nowrap; }
.ab-table thead .ab-col 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 .ab-col 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: var(--vip-content-accent-soft); color: var(--vip-content-accent-dark); }
.ab-empty { padding: 3rem; text-align: center; color: #9aa3b2; }
.ab-error { color: #dc2626; }
.ab-error .ab-btn { margin-top: 0.75rem; color: #1f2937; }
.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: var(--vip-content-accent); color: var(--vip-content-accent); }
.ab-page-btn:disabled { opacity: .45; cursor: not-allowed; }
.ab-page-no { padding: 0 0.375rem; }
.table-workspace{padding:1rem;color:#171717;font:400 .8125rem/1.5 "JetBrains Mono","Microsoft YaHei","PingFang SC",monospace}.tw-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.75rem;margin-bottom:.75rem}.tw-stat{padding:.875rem 1rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff}.tw-label,.tw-note,.tw-count{color:#595959;font-size:.75rem}.tw-value{margin-top:.375rem;font-size:1.375rem;font-weight:700;font-variant-numeric:tabular-nums}.tw-note{margin-top:.1875rem}.is-income{color:#53793e}.is-expense{color:#b80000}.tw-filter{display:flex;flex-wrap:wrap;gap:.75rem;margin-bottom:.75rem;padding:.875rem 1rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff}.tw-filter>label{display:flex;flex-direction:column;gap:.25rem;color:#595959;font-size:.75rem;font-weight:500}.tw-filter input,.tw-tools select{height:2.125rem;min-width:10rem;padding:0 .625rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff;color:#171717;font:400 .8125rem/1 inherit}.tw-filter input:focus,.tw-tools select:focus{border-color:#856632;box-shadow:0 0 0 3px rgba(133,102,50,.2);outline:none}.tw-filter .tw-keyword{flex:1;min-width:14rem}.tw-keyword input{width:100%}.tw-date span,.tw-actions,.tw-tools,.tw-page-actions{display:flex;align-items:center;gap:.5rem}.tw-date b{font-weight:400}.tw-date input{min-width:8.5rem}.tw-actions{align-self:flex-end;margin-left:auto}.tw-button,.tw-page-actions button{display:inline-flex;align-items:center;justify-content:center;gap:.375rem;height:2.125rem;padding:0 .875rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff;color:#171717;font:500 .8125rem/1 inherit;cursor:pointer}.tw-button:hover{border-color:#856632;color:#856632}.tw-primary{border-color:#856632;background:#856632;color:#fff}.tw-primary:hover{background:#6a5129;color:#fff}.tw-button .material-icons-outlined{font-size:1.125rem}.tw-panel{overflow:visible;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff}.tw-alert{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin:.75rem;padding:.75rem;border:1px solid #b80000;border-radius:.5rem;background:#fff4f4;color:#b80000}.tw-alert button{border:0;background:transparent;color:#b80000;font:inherit;font-weight:600;cursor:pointer}.tw-toolbar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;min-height:3.5rem;padding:.625rem .875rem;border-bottom:1px solid #d8d2c7}.tw-toolbar strong{margin-right:.625rem;font-size:.875rem}.tw-tools{flex-wrap:wrap;justify-content:flex-end}.tw-tools>select{min-width:13.75rem}.tw-scroll{position:relative;max-height:min(62vh,42rem);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable}.tw-table{width:max-content;min-width:100%;border-collapse:separate;border-spacing:0;font-size:.78125rem}.tw-table th,.tw-table td{min-width:7.5rem;padding:.5rem .625rem;border-right:1px solid #eeeae2;border-bottom:1px solid #eeeae2;white-space:nowrap}.tw-table thead th{position:sticky;z-index:4;background:#f7f0de;color:#171717;font-weight:600}.tw-groups th{top:0;height:2rem;text-align:center;color:#856632;font-size:.75rem}.tw-columns th{top:2rem;height:2.25rem;text-align:left;background:#fbfaf7;box-shadow:inset 0 -1px #d8d2c7}.tw-columns.is-single th{top:0}.tw-columns th.is-sortable{cursor:pointer}.tw-columns th.is-sortable:hover,.tw-sort{color:#856632}.tw-table tbody tr:hover{background:#f7f0de}.tw-table .is-number{text-align:right;font-variant-numeric:tabular-nums}.tw-table .is-id{color:#595959}.tw-tag{display:inline-block;padding:.0625rem .375rem;border-radius:.25rem;background:#f7f0de;color:#856632}.tw-empty{padding:3rem 1rem!important;text-align:center!important;color:#595959}.tw-pager{display:flex;align-items:center;justify-content:space-between;gap:.75rem;min-height:3.5rem;padding:.625rem .875rem;border-top:1px solid #d8d2c7;color:#595959}.tw-page-actions select{height:1.875rem;border:1px solid #d8d2c7;border-radius:.375rem}.tw-page-actions button{width:1.875rem;height:1.875rem;padding:0}.tw-page-actions button:disabled{cursor:not-allowed;opacity:.45}@media(max-width:900px){.table-workspace{padding:.75rem}.tw-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.tw-filter>label,.tw-filter .tw-keyword{width:100%}.tw-filter input{width:100%}.tw-date input{min-width:0}.tw-actions{margin-left:0}.tw-toolbar,.tw-pager{align-items:flex-start;flex-direction:column}.tw-tools{justify-content:flex-start}.tw-scroll{max-height:58vh;width:100%}}@media(max-width:520px){.tw-stats{grid-template-columns:1fr}.tw-date span{align-items:stretch;flex-direction:column}.tw-tools>select{min-width:0;max-width:100%}.tw-page-actions{flex-wrap:wrap}}
.tw-toolbar { flex-wrap: wrap; }
.tw-tools { flex: 1 1 24rem; min-width: 0; }
</style>

View File

@ -0,0 +1,224 @@
<template>
<div ref="root" class="table-column-settings">
<button
type="button"
class="table-column-settings__trigger"
:aria-expanded="open ? 'true' : 'false'"
aria-haspopup="dialog"
title="设置显示字段和字段顺序"
@click="open = !open"
>
<span class="material-icons-outlined" aria-hidden="true">view_column</span>
<span>列设置</span>
</button>
<div v-if="open" class="table-column-settings__menu" role="dialog" aria-label="列设置">
<div class="table-column-settings__head">
<strong>显示字段</strong>
<button type="button" class="table-column-settings__reset" @click="reset">
<span class="material-icons-outlined" aria-hidden="true">restart_alt</span>
恢复默认
</button>
</div>
<p class="table-column-settings__hint">拖动字段可调整表格和导出的字段顺序</p>
<div class="table-column-settings__list">
<div
v-for="(column, index) in workingColumns"
:key="column.key"
class="table-column-settings__item"
:class="{ 'is-dragging': dragIndex === index }"
draggable="true"
@dragstart="startDrag(index, $event)"
@dragover.prevent
@drop.prevent="dropAt(index)"
@dragend="dragIndex = null"
>
<span class="material-icons-outlined table-column-settings__drag" aria-hidden="true">drag_indicator</span>
<label>
<input
type="checkbox"
:checked="column.visible"
:disabled="column.fixed || (visibleCount === 1 && column.visible)"
@change="toggle(column.key)"
/>
<span>{{ column.label }}</span>
</label>
<span v-if="column.fixed" class="table-column-settings__fixed">固定</span>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'TableColumnSettings',
props: {
columns: { type: Array, required: true },
userKey: { type: [String, Number], required: true },
pageKey: { type: String, required: true },
tabKey: { type: String, required: true },
tableKey: { type: String, required: true },
},
data() {
return {
open: false,
workingColumns: [],
dragIndex: null,
};
},
computed: {
storageKey() {
return ['caiwu', 'table-columns', 'v1', this.userKey, this.pageKey, this.tabKey, this.tableKey]
.map((part) => encodeURIComponent(String(part)))
.join(':');
},
visibleCount() {
return this.workingColumns.filter((column) => column.visible).length;
},
},
watch: {
columns: {
immediate: true,
deep: true,
handler() {
this.restore();
},
},
},
mounted() {
document.addEventListener('click', this.onDocumentClick);
},
beforeDestroy() {
document.removeEventListener('click', this.onDocumentClick);
},
methods: {
normalizedDefaults() {
return this.columns.map((column, index) => ({
...column,
key: column.key || column.field || `column-${index}`,
visible: column.visible !== false,
fixed: column.fixed === true,
}));
},
restore() {
const defaults = this.normalizedDefaults();
let saved = null;
try {
saved = JSON.parse(window.localStorage.getItem(this.storageKey));
} catch (error) {
saved = null;
}
const savedMap = new Map(Array.isArray(saved) ? saved.map((item) => [item.key, item]) : []);
const ordered = defaults
.map((column, defaultIndex) => ({
...column,
visible: column.fixed ? true : (savedMap.has(column.key) ? savedMap.get(column.key).visible !== false : column.visible),
savedOrder: savedMap.has(column.key) ? Number(savedMap.get(column.key).order) : defaultIndex + defaults.length,
defaultIndex,
}))
.sort((left, right) => left.savedOrder - right.savedOrder || left.defaultIndex - right.defaultIndex)
.map(({ savedOrder, defaultIndex, ...column }) => column);
this.workingColumns = ordered;
this.emitChange(false);
},
toggle(key) {
const column = this.workingColumns.find((item) => item.key === key);
if (!column || column.fixed || (column.visible && this.visibleCount === 1)) return;
column.visible = !column.visible;
this.emitChange();
},
startDrag(index, event) {
this.dragIndex = index;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(index));
},
dropAt(index) {
if (this.dragIndex === null || this.dragIndex === index) return;
const next = this.workingColumns.slice();
const [moved] = next.splice(this.dragIndex, 1);
next.splice(index, 0, moved);
this.workingColumns = next;
this.dragIndex = null;
this.emitChange();
},
reset() {
window.localStorage.removeItem(this.storageKey);
this.workingColumns = this.normalizedDefaults();
this.emitChange(false);
},
emitChange(persist = true) {
if (persist) {
const value = this.workingColumns.map((column, order) => ({
key: column.key,
visible: column.visible,
order,
}));
window.localStorage.setItem(this.storageKey, JSON.stringify(value));
}
this.$emit('change', this.workingColumns.map((column) => ({ ...column })));
},
onDocumentClick(event) {
if (this.open && this.$refs.root && !this.$refs.root.contains(event.target)) {
this.open = false;
}
},
},
};
</script>
<style scoped>
.table-column-settings { position: relative; }
.table-column-settings__trigger,
.table-column-settings__reset {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
height: 2.125rem;
padding: 0 0.875rem;
border: 1px solid #d8d2c7;
border-radius: 0.5rem;
background: #fff;
color: #171717;
font: 500 0.8125rem/1 "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace;
cursor: pointer;
}
.table-column-settings__trigger:hover,
.table-column-settings__trigger:focus-visible { border-color: #856632; color: #856632; outline: none; }
.table-column-settings__trigger .material-icons-outlined { font-size: 1.125rem; }
.table-column-settings__menu {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
z-index: 30;
width: min(22rem, calc(100vw - 2rem));
padding: 0.75rem;
border: 1px solid #d8d2c7;
border-radius: 0.5rem;
background: #fff;
box-shadow: 0 4px 8px rgba(23, 23, 23, 0.1);
color: #171717;
}
.table-column-settings__head { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; font-size: 0.8125rem; }
.table-column-settings__reset { height: 1.875rem; padding: 0 0.5rem; border-color: transparent; color: #856632; font-size: 0.75rem; }
.table-column-settings__reset .material-icons-outlined { font-size: 1rem; }
.table-column-settings__hint { margin: 0.375rem 0 0.625rem; color: #595959; font-size: 0.75rem; }
.table-column-settings__list { max-height: min(24rem, 60vh); overflow-y: auto; border-top: 1px solid #d8d2c7; }
.table-column-settings__item { display: flex; align-items: center; min-height: 2.25rem; border-bottom: 1px solid #eeeae2; font-size: 0.78125rem; }
.table-column-settings__item.is-dragging { background: #f7f0de; }
.table-column-settings__item label { display: flex; flex: 1; align-items: center; gap: 0.5rem; cursor: pointer; }
.table-column-settings__item input { accent-color: #856632; }
.table-column-settings__drag { color: #856632; cursor: grab; font-size: 1.125rem; }
.table-column-settings__fixed { color: #595959; font-size: 0.71875rem; }
</style>

View File

@ -1,482 +1,119 @@
<template>
<div class="taobao-page">
<!-- 汇总卡片 -->
<div class="tb-stats">
<div class="tb-stat">
<div class="tb-stat-k">收入合计</div>
<div class="tb-stat-v income">{{ money(summary.income) }}</div>
<div class="tb-stat-s">{{ summary.count }} 笔交易</div>
</div>
<div class="tb-stat">
<div class="tb-stat-k">支出合计</div>
<div class="tb-stat-v expense">{{ money(summary.expense) }}</div>
<div class="tb-stat-s">按当前筛选</div>
</div>
<div class="tb-stat">
<div class="tb-stat-k">收支净额</div>
<div class="tb-stat-v">{{ money(summary.balance) }}</div>
<div class="tb-stat-s">收入合计 - 支出合计</div>
</div>
<div class="tb-stat">
<div class="tb-stat-k">交易笔数</div>
<div class="tb-stat-v">{{ summary.count }}</div>
<div class="tb-stat-s">当前筛选结果</div>
<div class="table-workspace">
<div class="tw-stats">
<div v-for="stat in stats" :key="stat.label" class="tw-stat">
<div class="tw-label">{{ stat.label }}</div>
<div class="tw-value" :class="stat.className">{{ stat.value }}</div>
<div class="tw-note">{{ stat.note }}</div>
</div>
</div>
<!-- 筛选栏 -->
<div class="tb-filter">
<div class="tb-field">
<label>原始店铺</label>
<input v-model.trim="filters.store_name" placeholder="如 天猫-亿家达旗舰店" />
</div>
<div class="tb-field">
<label>平台渠道</label>
<input v-model.trim="filters.alipay_account" placeholder="如 CommonBillTGCPay" />
</div>
<div class="tb-field">
<label>账单项目</label>
<input v-model.trim="filters.bill_item" placeholder="如 交易佣金" />
</div>
<div class="tb-field">
<label>收支类型</label>
<select v-model="filters.business_type">
<option value="">全部类型</option>
<option>收入</option>
<option>支出</option>
</select>
</div>
<div class="tb-field tb-field-date">
<label>发生时间</label>
<div class="tb-date">
<input type="date" v-model="filters.start" /> <span>~</span>
<input type="date" v-model="filters.end" />
<form class="tw-filter" @submit.prevent="applyFilters">
<label>原始店铺<input v-model.trim="filters.store_name" placeholder="如 天猫-亿家达旗舰店" /></label>
<label>平台渠道<input v-model.trim="filters.alipay_account" placeholder="如 CommonBillTGCPay" /></label>
<label>账单项目<input v-model.trim="filters.bill_item" placeholder="如 交易佣金" /></label>
<label>收支类型<select v-model="filters.business_type"><option value="">全部类型</option><option>收入</option><option>支出</option></select></label>
<label class="tw-date">发生时间<span><input v-model="filters.start" type="date" aria-label="开始日期" /><b>至</b><input v-model="filters.end" type="date" aria-label="结束日期" /></span></label>
<label class="tw-keyword">关键词(费用 / 订单 / 商品编码)<input v-model.trim="filters.keyword" placeholder="输入费用名称、订单号或商品编码" /></label>
<div class="tw-actions"><button class="tw-button tw-primary" type="submit">查询</button><button class="tw-button" type="button" @click="resetFilters">重置</button></div>
</form>
<section class="tw-panel" aria-label="淘系支付宝账户账单明细">
<div v-if="error" class="tw-alert" role="alert"><span>{{ error }}</span><button type="button" @click="fetch">重新加载</button></div>
<div class="tw-toolbar">
<div><strong>账单明细</strong><span v-if="!loading" class="tw-count">{{ total }} 条</span></div>
<div class="tw-tools">
<select v-model="exportMode" title="选择导出范围" aria-label="导出范围">
<option value="page-visible">当前页 · 当前显示列</option>
<option value="filtered-visible">全部筛选结果 · 当前显示列</option>
<option value="filtered-all">全部筛选结果 · 全部字段</option>
</select>
<button type="button" class="tw-button" @click="exportRows"><span class="material-icons-outlined" aria-hidden="true">download</span>导出</button>
<table-column-settings :columns="defaultColumns" :user-key="userKey" page-key="store-receiving-bills" tab-key="taobao-alipay" table-key="bill-details" @change="configuredColumns = $event" />
</div>
</div>
<div class="tb-field tb-field-kw">
<label>关键词(费用 / 订单 / 商品编码)</label>
<input v-model.trim="filters.keyword" placeholder="输入费用名称、订单号或商品编码" />
</div>
<div class="tb-actions">
<button class="tb-btn tb-btn-primary" @click="applyFilters">查询</button>
<button class="tb-btn" @click="resetFilters">重置</button>
<button class="tb-btn" @click="exportRows">导出</button>
</div>
</div>
<!-- 数据表 -->
<div class="tb-panel">
<div v-if="error" class="tb-error" role="alert">{{ error }}</div>
<div class="tb-phead">
<div class="tb-title">账单明细</div>
<div class="tb-count" v-if="!loading">{{ total }} 条</div>
</div>
<div class="tb-scroll">
<table class="tb-table" v-if="!loading">
<div ref="scroll" class="tw-scroll">
<table v-if="!loading && configuredColumns.length" class="tw-table">
<thead>
<tr class="tb-group">
<th colspan="4">基础信息</th>
<th colspan="8">账户与交易</th>
<th colspan="3">金额(元)</th>
<th colspan="6">业务关联</th>
<th colspan="4">维护信息</th>
</tr>
<tr class="tb-col">
<th @click="toggleSort('record_code')">记录编码 <span class="tb-sort">⇅</span></th>
<th>原始店铺</th>
<th>自定义备注</th>
<th>账单项目</th>
<th>平台渠道</th>
<th>账务订单号</th>
<th>业务流水号</th>
<th>商户订单号</th>
<th>业务基础订单号</th>
<th>商品编码</th>
<th>对方账号</th>
<th @click="toggleSort('occurred_at')">发生时间 <span class="tb-sort">⇅</span></th>
<th class="tb-num" @click="toggleSort('income')">收入 <span class="tb-sort">⇅</span></th>
<th class="tb-num" @click="toggleSort('expense')">支出 <span class="tb-sort">⇅</span></th>
<th class="tb-num" @click="toggleSort('balance')">收支净额 <span class="tb-sort">⇅</span></th>
<th>业务类型</th>
<th>备注</th>
<th>线上单号</th>
<th>线上单号对应实际店铺</th>
<th>业务描述</th>
<th>业务账单来源</th>
<th>业务订单号</th>
<th>创建时间</th>
<th>修改时间</th>
<th>修改人</th>
<tr class="tw-groups"><th v-for="(group, index) in visibleGroups" :key="`${group.label}-${index}`" :colspan="group.span" scope="colgroup">{{ group.label }}</th></tr>
<tr class="tw-columns">
<th v-for="column in visibleColumns" :key="column.key" :class="{ 'is-number': isNumber(column.kind), 'is-sortable': sortableField(column.field) }" scope="col" @click="maybeSort(column)">
{{ column.label }} <span v-if="sortableField(column.field)" class="tw-sort" aria-hidden="true">{{ sortMark(column.field) }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="r in rows" :key="r.id">
<td class="tb-mono">{{ r.record_code }}</td>
<td>{{ r.store_name }}</td>
<td class="tb-muted">{{ r.custom_remark || '—' }}</td>
<td><span class="tb-tag">{{ r.bill_item }}</span></td>
<td class="tb-mono">{{ r.alipay_account }}</td>
<td class="tb-mono">{{ r.account_order_no }}</td>
<td class="tb-mono">{{ r.business_serial_no }}</td>
<td class="tb-mono">{{ r.merchant_order_no || '—' }}</td>
<td class="tb-mono tb-muted">{{ r.business_base_order_no || '—' }}</td>
<td class="tb-ellipsis">{{ r.goods_name }}</td>
<td>{{ r.counterparty_account || '—' }}</td>
<td>{{ r.occurred_at || '—' }}</td>
<td class="tb-num tb-in" v-if="r.income > 0">+{{ money(r.income) }}</td>
<td class="tb-num tb-in" v-else>—</td>
<td class="tb-num tb-out" v-if="r.expense > 0">−{{ money(r.expense) }}</td>
<td class="tb-num tb-out" v-else>—</td>
<td class="tb-num">{{ money(r.balance) }}</td>
<td><span class="tb-tag" :class="tagClass(r.business_type)">{{ r.business_type || '—' }}</span></td>
<td class="tb-muted tb-ellipsis">{{ r.remark || '—' }}</td>
<td class="tb-mono">{{ r.online_order_no || '—' }}</td>
<td>{{ r.online_real_store || '—' }}</td>
<td class="tb-muted tb-ellipsis">{{ r.business_description || '—' }}</td>
<td class="tb-muted">{{ r.business_bill_source || '—' }}</td>
<td class="tb-mono tb-muted">{{ r.business_order_no || '—' }}</td>
<td class="tb-muted">{{ r.created_at }}</td>
<td class="tb-muted">{{ r.updated_at }}</td>
<td class="tb-muted">{{ r.modifier || '—' }}</td>
</tr>
<tr v-if="!rows.length" class="tb-empty-row">
<td :colspan="25" class="tb-empty">没有符合条件的账单记录</td>
<tr v-for="row in rows" :key="row.id || row.record_code">
<td v-for="column in visibleColumns" :key="column.key" :class="cellClass(column)"><span v-if="column.kind === 'tag'" class="tw-tag">{{ displayValue(row, column) }}</span><template v-else>{{ displayValue(row, column) }}</template></td>
</tr>
<tr v-if="!rows.length"><td :colspan="visibleColumns.length" class="tw-empty">没有符合条件的账单记录</td></tr>
</tbody>
</table>
<div v-else class="tb-empty">加载中…</div>
<div v-else-if="loading" class="tw-empty" role="status">加载中…</div>
</div>
<!-- 分页 -->
<div class="tb-pager" v-if="rows.length">
<div class="tb-page-info">
第 {{ rangeStart }} - {{ rangeEnd }} 条 / 共 {{ total }} 条
</div>
<div class="tb-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="tb-page-btn" :disabled="currentPage <= 1" @click="changePage(1)">«</button>
<button class="tb-page-btn" :disabled="currentPage <= 1" @click="changePage(currentPage - 1)">‹</button>
<span class="tb-page-no">{{ currentPage }} / {{ lastPage }}</span>
<button class="tb-page-btn" :disabled="currentPage >= lastPage" @click="changePage(currentPage + 1)">›</button>
<button class="tb-page-btn" :disabled="currentPage >= lastPage" @click="changePage(lastPage)">»</button>
<div v-if="rows.length" class="tw-pager">
<div>第 {{ rangeStart }} - {{ rangeEnd }} 条 / 共 {{ total }} 条</div>
<div class="tw-page-actions">
<label>每页 <select v-model.number="perPage" @change="changePage(1)"><option :value="10">10</option><option :value="20">20</option><option :value="50">50</option></select></label>
<button type="button" :disabled="currentPage <= 1" title="第一页" @click="changePage(1)">«</button><button type="button" :disabled="currentPage <= 1" title="上一页" @click="changePage(currentPage - 1)">‹</button>
<span>{{ currentPage }} / {{ lastPage }}</span>
<button type="button" :disabled="currentPage >= lastPage" title="下一页" @click="changePage(currentPage + 1)">›</button><button type="button" :disabled="currentPage >= lastPage" title="最后一页" @click="changePage(lastPage)">»</button>
</div>
</div>
</div>
</section>
</div>
</template>
<script>
import TableColumnSettings from './TableColumnSettings.vue';
const COLUMNS = [
['记录编码', 'record_code', 'num', '基础信息'], ['原始店铺', 'store_name', 'text', '基础信息'], ['自定义备注', 'custom_remark', 'text', '基础信息'], ['账单项目', 'bill_item', 'tag', '基础信息'],
['平台渠道', 'alipay_account', 'num', '账户与交易'], ['账务订单号', 'account_order_no', 'num', '账户与交易'], ['业务流水号', 'business_serial_no', 'num', '账户与交易'], ['商户订单号', 'merchant_order_no', 'num', '账户与交易'], ['业务基础订单号', 'business_base_order_no', 'num', '账户与交易'], ['商品编码', 'goods_name', 'num', '账户与交易'], ['对方账号', 'counterparty_account', 'text', '账户与交易'], ['发生时间', 'occurred_at', 'date', '账户与交易'],
['收入', 'income', 'money_in', '金额(元)'], ['支出', 'expense', 'money_out', '金额(元)'], ['收支净额', 'balance', 'money', '金额(元)'],
['业务类型', 'business_type', 'tag', '业务关联'], ['备注', 'remark', 'text', '业务关联'], ['线上单号', 'online_order_no', 'num', '业务关联'], ['线上单号对应实际店铺', 'online_real_store', 'text', '业务关联'], ['业务描述', 'business_description', 'text', '业务关联'], ['业务账单来源', 'business_bill_source', 'text', '业务关联'],
['业务订单号', 'business_order_no', 'num', '维护信息'], ['创建时间', 'created_at', 'date', '维护信息'], ['修改时间', 'updated_at', 'date', '维护信息'], ['修改人', 'modifier', 'text', '维护信息'],
].map(([label, field, kind, group]) => ({ key: field, label, field, kind, group }));
const SORTABLE = ['occurred_at', 'income', 'expense', 'balance', 'record_code'];
export default {
name: 'TaobaoAlipayBills',
props: {
apiBase: { type: String, required: true },
exportBase: { type: String, required: true },
},
data() {
return {
loading: false,
error: '',
rows: [],
summary: { income: 0, expense: 0, balance: 0, count: 0 },
total: 0,
currentPage: 1,
lastPage: 1,
perPage: 10,
filters: {
store_name: '',
alipay_account: '',
bill_item: '',
business_type: '',
start: '',
end: '',
keyword: '',
},
sort: { key: 'occurred_at', dir: 'desc' },
};
},
name: 'TaobaoAlipayBills', components: { TableColumnSettings },
props: { apiBase: { type: String, required: true }, exportBase: { type: String, required: true } },
data() { return { loading: false, error: '', rows: [], configuredColumns: [], summary: { income: 0, expense: 0, balance: 0, count: 0 }, total: 0, currentPage: 1, lastPage: 1, perPage: 20, filters: { store_name: '', alipay_account: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }, sort: { key: 'occurred_at', dir: 'desc' }, exportMode: 'filtered-visible' }; },
computed: {
rangeStart() {
return this.total === 0 ? 0 : (this.currentPage - 1) * this.perPage + 1;
},
rangeEnd() {
return Math.min(this.currentPage * this.perPage, this.total);
},
userKey() { const mount = document.querySelector('.business-blank-page'); return mount && mount.dataset.tablePreferencesUser ? mount.dataset.tablePreferencesUser : 'anonymous'; },
defaultColumns() { return COLUMNS; }, visibleColumns() { return this.configuredColumns.filter((column) => column.visible); },
visibleGroups() { return this.visibleColumns.reduce((groups, column) => { const last = groups[groups.length - 1]; if (last && last.label === column.group) last.span += 1; else groups.push({ label: column.group, span: 1 }); return groups; }, []); },
stats() { return [{ label: '收入合计', value: this.money(this.summary.income), note: `${this.summary.count} 笔交易`, className: 'is-income' }, { label: '支出合计', value: this.money(this.summary.expense), note: '按当前筛选', className: 'is-expense' }, { label: '收支净额', value: this.money(this.summary.balance), note: '收入合计 - 支出合计', className: '' }, { label: '交易笔数', value: this.summary.count, note: '当前筛选结果', className: '' }]; },
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();
},
mounted() { this.fetch(); },
methods: {
async fetch() {
this.loading = true;
this.error = '';
const params = {
...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 };
} catch (e) {
console.error('加载淘系支付宝账单失败', e);
this.rows = [];
this.total = 0;
this.summary = { income: 0, expense: 0, balance: 0, count: 0 };
this.error = (e.response && e.response.data && e.response.data.message)
|| '淘系支付宝账单暂时无法读取,请稍后重试。';
} finally {
this.loading = false;
}
},
applyFilters() {
this.currentPage = 1;
this.fetch();
},
resetFilters() {
this.filters = {
store_name: '',
alipay_account: '',
bill_item: '',
business_type: '',
start: '',
end: '',
keyword: '',
};
this.currentPage = 1;
this.fetch();
},
changePage(p) {
this.currentPage = p;
this.fetch();
},
exportRows() {
const params = new URLSearchParams(this.filters);
window.location.href = `${this.exportBase}?${params.toString()}`;
},
toggleSort(key) {
if (this.sort.key === key) {
this.sort.dir = this.sort.dir === 'asc' ? 'desc' : 'asc';
} else {
this.sort.key = key;
this.sort.dir = 'desc';
}
this.currentPage = 1;
this.fetch();
},
tagClass(type) {
if (type === '交易分账') return 'tb-tag-amber';
if (type === '退款') return 'tb-tag-gray';
return '';
},
money(n) {
const v = Number(n || 0);
return '¥' + v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
},
async fetch() { this.loading = true; this.error = ''; try { const response = await window.axios.get(this.apiBase, { params: { ...this.filters, sort: this.sort.key, dir: this.sort.dir, limit: this.perPage, page: this.currentPage } }); const body = response.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 }; } catch (error) { this.rows = []; this.total = 0; this.summary = { income: 0, expense: 0, balance: 0, count: 0 }; this.error = (error.response && error.response.data && error.response.data.message) || '淘系支付宝账单暂时无法读取,请稍后重试。'; } finally { this.loading = false; } },
applyFilters() { this.currentPage = 1; this.fetch(); }, resetFilters() { this.filters = { store_name: '', alipay_account: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }; this.applyFilters(); },
changePage(page) { this.currentPage = page; this.fetch(); this.$nextTick(() => { if (this.$refs.scroll) this.$refs.scroll.scrollTop = 0; }); },
maybeSort(column) { if (!this.sortableField(column.field)) return; this.sort.dir = this.sort.key === column.field && this.sort.dir === 'desc' ? 'asc' : 'desc'; this.sort.key = column.field; this.applyFilters(); }, sortableField(field) { return SORTABLE.indexOf(field) !== -1; }, sortMark(field) { return this.sort.key === field ? (this.sort.dir === 'asc' ? '↑' : '↓') : '↕'; },
rawValue(row, column) { const value = row[column.field]; return value === null || value === undefined ? '' : value; },
displayValue(row, column) { const value = this.rawValue(row, column); if (value === '') return '—'; if (column.kind === 'money_in') return Number(value) > 0 ? `+${this.money(value)}` : '—'; if (column.kind === 'money_out') return Number(value) > 0 ? `-${this.money(value)}` : '—'; if (column.kind === 'money') return this.money(value); return value; },
cellClass(column) { return { 'is-number': this.isNumber(column.kind), 'is-id': column.kind === 'num', 'is-income': column.kind === 'money_in', 'is-expense': column.kind === 'money_out' }; }, isNumber(kind) { return ['num', 'money', 'money_in', 'money_out'].indexOf(kind) !== -1; }, money(value) { return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; },
exportRows() { if (this.exportMode === 'page-visible') { this.downloadCurrentPage(); return; } const params = new URLSearchParams(this.filters); params.set('sort', this.sort.key); params.set('dir', this.sort.dir); if (this.exportMode === 'filtered-visible') this.visibleColumns.forEach((column) => params.append('columns[]', column.field)); window.location.assign(`${this.exportBase}?${params.toString()}`); },
downloadCurrentPage() { const csvRows = [this.visibleColumns.map((column) => column.label), ...this.rows.map((row) => this.visibleColumns.map((column) => this.rawValue(row, column)))]; const csv = csvRows.map((values) => values.map(this.csvCell).join(',')).join('\r\n'); const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' })); link.download = `taobao_alipay_bills_page_${this.currentPage}.csv`; link.click(); URL.revokeObjectURL(link.href); },
csvCell(value) { const text = String(value === null || value === undefined ? '' : value); const safe = /^[=+\-@]/.test(text) ? `'${text}` : text; return `"${safe.replace(/"/g, '""')}"`; },
},
};
</script>
<style scoped>
.taobao-page {
padding: 1.25rem 1.5rem 1.5rem;
}
.tb-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.875rem;
margin-bottom: 0.875rem;
}
.tb-stat {
background: #ffffff;
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);
}
.tb-stat-k { font-size: 0.75rem; color: #6b7280; }
.tb-stat-v { font-size: 1.375rem; font-weight: 700; margin-top: 0.5rem; letter-spacing: .3px; }
.tb-stat-s { font-size: 0.75rem; color: #9aa3b2; margin-top: 0.25rem; }
.tb-stat-v.income { color: #f5222d; }
.tb-stat-v.expense { color: #16a34a; }
.tb-filter {
display: flex;
flex-wrap: wrap;
gap: 0.875rem;
background: #ffffff;
border: 1px solid #e6e9f0;
border-radius: 0.75rem;
padding: 1rem 1.125rem;
margin-bottom: 0.875rem;
}
.tb-field { display: flex; flex-direction: column; gap: 0.3125rem; }
.tb-field label { font-size: 0.75rem; color: #6b7280; font-weight: 500; }
.tb-field input,
.tb-field select {
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;
}
.tb-field input:focus,
.tb-field select:focus { border-color: var(--vip-content-accent); box-shadow: 0 0 0 3px rgba(185, 149, 79, .16); }
.tb-field-kw { flex: 1; min-width: 12.5rem; }
.tb-field-kw input { width: 100%; }
.tb-date { display: flex; align-items: center; gap: 0.375rem; }
.tb-date input { min-width: 8.75rem; }
.tb-actions { display: flex; gap: 0.625rem; align-items: flex-end; margin-left: auto; }
.tb-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;
}
.tb-btn:hover { border-color: #c7cdda; }
.tb-btn-primary { background: var(--vip-content-accent); border-color: var(--vip-content-accent); color: #fff; }
.tb-btn-primary:hover { background: var(--vip-content-accent-dark); }
.tb-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;
}
.tb-error {
margin: 1rem;
border: 1px solid #d8d2c7;
border-radius: 0.5rem;
background: #f7f0de;
padding: 0.75rem 1rem;
color: #935f07;
font-size: 0.8125rem;
}
.tb-phead { display: flex; align-items: center; gap: 0.625rem; padding: 0.875rem 1rem; border-bottom: 1px solid #e6e9f0; }
.tb-title { font-weight: 600; font-size: 0.875rem; }
.tb-count { color: #9aa3b2; font-size: 0.75rem; }
.tb-scroll { overflow-x: auto; }
.tb-table { border-collapse: collapse; width: 100%; min-width: 2200px; font-size: 0.78125rem; }
.tb-table thead .tb-group th {
background: #f0f3f9;
color: #6b7280;
font-weight: 600;
text-align: center;
border-bottom: 1px solid #e6e9f0;
padding: 0.5625rem 0.625rem;
font-size: 0.75rem;
white-space: nowrap;
}
.tb-table thead .tb-col th {
background: #fafbfd;
color: #1f2937;
font-weight: 600;
text-align: left;
border-bottom: 1px solid #e6e9f0;
padding: 0.5625rem 0.625rem;
white-space: nowrap;
cursor: default;
position: relative;
}
.tb-table thead .tb-col th.tb-num { text-align: right; }
.tb-sort { color: #9aa3b2; cursor: pointer; margin-left: 0.1875rem; }
.tb-sort:hover { color: var(--vip-content-accent); }
.tb-table tbody td {
padding: 0.5625rem 0.625rem;
border-bottom: 1px solid #f1f3f7;
white-space: nowrap;
color: #1f2937;
}
.tb-table tbody tr:hover { background: #f7f9ff; }
.tb-num { text-align: right; font-variant-numeric: tabular-nums; font-family: ui-monospace, Menlo, Consolas, monospace; }
.tb-mono { font-family: ui-monospace, Menlo, Consolas, monospace; color: #6b7280; font-size: 0.71875rem; }
.tb-muted { color: #9aa3b2; }
.tb-ellipsis { max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
.tb-in { color: #f5222d; font-weight: 600; }
.tb-out { color: #16a34a; font-weight: 600; }
.tb-tag {
display: inline-block;
padding: 0.0625rem 0.5rem;
border-radius: 1rem;
font-size: 0.71875rem;
font-weight: 500;
background: var(--vip-content-accent-soft);
color: var(--vip-content-accent-dark);
}
.tb-tag-amber { background: #fff4e5; color: #b76e00; }
.tb-tag-gray { background: #eef1f6; color: #6b7280; }
.tb-empty { padding: 3rem; text-align: center; color: #9aa3b2; }
.tb-pager {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.875rem 1rem;
border-top: 1px solid #e6e9f0;
flex-wrap: wrap;
}
.tb-page-info { color: #6b7280; font-size: 0.78125rem; }
.tb-page-right { margin-left: auto; display: flex; align-items: center; gap: 0.375rem; font-size: 0.78125rem; }
.tb-page-right select { height: 1.875rem; border: 1px solid #e6e9f0; border-radius: 0.4375rem; padding: 0 0.375rem; }
.tb-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;
}
.tb-page-btn:hover:not(:disabled) { border-color: var(--vip-content-accent); color: var(--vip-content-accent); }
.tb-page-btn:disabled { opacity: .45; cursor: not-allowed; }
.tb-page-no { padding: 0 0.375rem; }
.business-page-empty {
padding: 3rem;
text-align: center;
color: #9aa3b2;
font-size: 0.875rem;
}
.table-workspace { padding: 1rem; color: #171717; font: 400 0.8125rem/1.5 "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace; }
.tw-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .75rem; margin-bottom: .75rem; }.tw-stat { padding: .875rem 1rem; border: 1px solid #d8d2c7; border-radius: .5rem; background: #fff; }.tw-label,.tw-note,.tw-count { color: #595959; font-size: .75rem; }.tw-value { margin-top: .375rem; font-size: 1.375rem; font-weight: 700; font-variant-numeric: tabular-nums; }.tw-note { margin-top: .1875rem; }.is-income { color: #53793e; }.is-expense { color: #b80000; }
.tw-filter { display: flex; flex-wrap: wrap; gap: .75rem; margin-bottom: .75rem; padding: .875rem 1rem; border: 1px solid #d8d2c7; border-radius: .5rem; background: #fff; }.tw-filter>label { display: flex; flex-direction: column; gap: .25rem; color: #595959; font-size: .75rem; font-weight: 500; }.tw-filter input,.tw-filter select,.tw-tools select { height: 2.125rem; min-width: 10rem; padding: 0 .625rem; border: 1px solid #d8d2c7; border-radius: .5rem; background: #fff; color: #171717; font: 400 .8125rem/1 inherit; }.tw-filter input:focus,.tw-filter select:focus,.tw-tools select:focus { border-color: #856632; box-shadow: 0 0 0 3px rgba(133,102,50,.2); outline: none; }.tw-filter .tw-keyword { flex: 1; min-width: 14rem; }.tw-keyword input { width: 100%; }.tw-date span,.tw-actions,.tw-tools,.tw-page-actions { display: flex; align-items: center; gap: .5rem; }.tw-date b { font-weight: 400; }.tw-date input { min-width: 8.5rem; }.tw-actions { align-self: flex-end; margin-left: auto; }
.tw-button,.tw-page-actions button { display: inline-flex; align-items: center; justify-content: center; gap: .375rem; height: 2.125rem; padding: 0 .875rem; border: 1px solid #d8d2c7; border-radius: .5rem; background: #fff; color: #171717; font: 500 .8125rem/1 inherit; cursor: pointer; }.tw-button:hover { border-color: #856632; color: #856632; }.tw-primary { border-color: #856632; background: #856632; color: #fff; }.tw-primary:hover { background: #6a5129; color: #fff; }.tw-button .material-icons-outlined { font-size: 1.125rem; }
.tw-panel { overflow: visible; border: 1px solid #d8d2c7; border-radius: .5rem; background: #fff; }.tw-alert { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin: .75rem; padding: .75rem; border: 1px solid #935f07; border-radius: .5rem; background: #f7f0de; color: #935f07; }.tw-alert button { border: 0; background: transparent; color: #856632; font: inherit; font-weight: 600; cursor: pointer; }.tw-toolbar { display: flex; align-items: center; justify-content: space-between; gap: .75rem; min-height: 3.5rem; padding: .625rem .875rem; border-bottom: 1px solid #d8d2c7; }.tw-toolbar strong { margin-right: .625rem; font-size: .875rem; }.tw-tools { flex-wrap: wrap; justify-content: flex-end; }.tw-tools>select { min-width: 13.75rem; }
.tw-scroll { position: relative; max-height: min(62vh, 42rem); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }.tw-table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; font-size: .78125rem; }.tw-table th,.tw-table td { min-width: 7.5rem; padding: .5rem .625rem; border-right: 1px solid #eeeae2; border-bottom: 1px solid #eeeae2; white-space: nowrap; }.tw-table thead th { position: sticky; z-index: 4; background: #f7f0de; color: #171717; font-weight: 600; }.tw-groups th { top: 0; height: 2rem; text-align: center; color: #856632; font-size: .75rem; }.tw-columns th { top: 2rem; height: 2.25rem; text-align: left; background: #fbfaf7; box-shadow: inset 0 -1px #d8d2c7; }.tw-columns th.is-sortable { cursor: pointer; }.tw-columns th.is-sortable:hover,.tw-sort { color: #856632; }.tw-table tbody tr:hover { background: #f7f0de; }.tw-table .is-number { text-align: right; font-variant-numeric: tabular-nums; }.tw-table .is-id { color: #595959; }.tw-tag { display: inline-block; padding: .0625rem .375rem; border-radius: .25rem; background: #f7f0de; color: #856632; }.tw-empty { padding: 3rem 1rem!important; text-align: center!important; color: #595959; }
.tw-pager { display: flex; align-items: center; justify-content: space-between; gap: .75rem; min-height: 3.5rem; padding: .625rem .875rem; border-top: 1px solid #d8d2c7; color: #595959; }.tw-page-actions select { height: 1.875rem; border: 1px solid #d8d2c7; border-radius: .375rem; }.tw-page-actions button { width: 1.875rem; height: 1.875rem; padding: 0; }.tw-page-actions button:disabled { cursor: not-allowed; opacity: .45; }
@media(max-width:900px){.table-workspace{padding:.75rem}.tw-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.tw-filter>label,.tw-filter .tw-keyword{width:100%}.tw-filter input,.tw-filter select{width:100%}.tw-date input{min-width:0}.tw-actions{margin-left:0}.tw-toolbar,.tw-pager{align-items:flex-start;flex-direction:column}.tw-tools{justify-content:flex-start}.tw-scroll{max-height:58vh;width:100%}}@media(max-width:520px){.tw-stats{grid-template-columns:1fr}.tw-date span{align-items:stretch;flex-direction:column}.tw-tools>select{min-width:0;max-width:100%}.tw-page-actions{flex-wrap:wrap}}
.tw-toolbar { flex-wrap: wrap; }
.tw-tools { flex: 1 1 24rem; min-width: 0; }
</style>

View File

@ -1060,6 +1060,7 @@
'biz-card mt-6',
'business-blank-page' => ($page['slug'] ?? null) === 'store-receiving-bills',
])
@if (($page['slug'] ?? null) === 'store-receiving-bills') data-table-preferences-user="{{ user()->id }}" @endif
aria-label="{{ $activeTab['title'] ?? ($page['title'] ?? $section['title']) }}"
>
<div class="biz-empty">