webadmin端增加商铺日志

This commit is contained in:
2026-07-07 12:12:12 +08:00
parent e25b208545
commit 6572799264
20 changed files with 919 additions and 5 deletions
+2
View File
@@ -27,6 +27,7 @@ import TicketsPage from './pages/TicketsPage';
import UserLogsPage from './pages/UserLogsPage';
import HqLogsPage from './pages/HqLogsPage';
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
import StoreLogsPage from './pages/StoreLogsPage';
function RequireAuth({ children }: { children: React.ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />;
@@ -65,6 +66,7 @@ export default function App() {
<Route path="/partner-bills" element={<PartnerBillsPage />} />
<Route path="/tickets" element={<TicketsPage />} />
<Route path="/logs/users" element={<UserLogsPage />} />
<Route path="/logs/stores" element={<StoreLogsPage />} />
<Route path="/logs/hq" element={<HqLogsPage />} />
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
<Route path="/deliveries" element={<DeliveriesPage />} />
@@ -73,6 +73,7 @@ const MENU_ITEMS: MenuProps['items'] = [
label: '日志',
children: [
{ key: '/logs/users', label: '用户日志' },
{ key: '/logs/stores', label: '商户日志' },
{ key: '/logs/hq', label: 'HQ 操作日志' },
{ key: '/logs/third-party', label: '第三方日志' },
],
+6
View File
@@ -0,0 +1,6 @@
export {
STORE_LOG_CATEGORY_OPTIONS,
STORE_LOG_CATEGORY_LABELS,
resolveStoreLogCategory,
type StoreLogCategory,
} from '@dukang/shared-types';
+198
View File
@@ -0,0 +1,198 @@
import { useEffect, useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom';
import {
STORE_LOG_CATEGORY_OPTIONS,
STORE_LOG_CATEGORY_LABELS,
resolveStoreLogCategory,
type StoreLogCategory,
} from '../lib/store-log';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
source: 'analytics' | 'redeem_record' | 'store_payout';
storeId: string;
storeAccountId: string | null;
storeName: string | null;
accountName: string | null;
accountPhone: string | null;
category: StoreLogCategory | null;
eventName: string;
clientApp: string | null;
refType: string | null;
refId: string | null;
extraJson: Record<string, unknown> | null;
createdAt: string;
};
const SOURCE_LABELS: Record<Row['source'], string> = {
analytics: '行为埋点',
redeem_record: '核销记录',
store_payout: '打款记录',
};
function summarizeExtra(json: Record<string, unknown> | null) {
if (!json) return '—';
const text = JSON.stringify(json);
return text.length > 80 ? `${text.slice(0, 80)}` : text;
}
function parseCompositeId(id: string) {
const idx = id.indexOf(':');
if (idx <= 0) return null;
return { source: id.slice(0, idx), rawId: id.slice(idx + 1) };
}
export default function StoreLogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm();
const [category, setCategory] = useState(searchParams.get('category') ?? '');
const [filters, setFilters] = useState<Record<string, string>>(() => ({
storeId: searchParams.get('storeId') ?? '',
storeAccountId: searchParams.get('storeAccountId') ?? '',
phone: searchParams.get('phone') ?? '',
storeName: searchParams.get('storeName') ?? '',
eventName: searchParams.get('eventName') ?? '',
}));
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/logs/stores',
() => {
const qs = new URLSearchParams();
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.storeAccountId) qs.set('storeAccountId', filters.storeAccountId);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.storeName) qs.set('storeName', filters.storeName);
if (filters.eventName) qs.set('eventName', filters.eventName);
if (category) qs.set('category', category);
return qs;
},
[filters, category],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
form.setFieldsValue(filters);
}, [form, filters]);
const columns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '门店', width: 180,
render: (_, r) => (
<div>
<div>{r.storeName || '—'}</div>
<div style={{ color: '#999', fontSize: 12 }}>{r.storeId}</div>
</div>
),
},
{
title: '账号', width: 150,
render: (_, r) => (
<div>
<div>{r.accountName || '—'}</div>
<div style={{ color: '#999', fontSize: 12 }}>{r.accountPhone || r.storeAccountId || '系统/HQ'}</div>
</div>
),
},
{
title: '分类', dataIndex: 'category', width: 100,
render: (v: StoreLogCategory | null, r) => (
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
),
},
{ title: '事件', dataIndex: 'eventName', width: 160 },
{
title: '来源', dataIndex: 'source', width: 100,
render: (v: Row['source']) => SOURCE_LABELS[v] || v,
},
{
title: '关联', width: 120,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
},
{
title: '摘要', ellipsis: true,
render: (_, r) => summarizeExtra(r.extraJson),
},
{
title: '操作', width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
const parsed = parseCompositeId(row.id);
if (!parsed) return;
setDetail(await request(`/admin/logs/stores/${parsed.source}/${parsed.rawId}`));
setDrawerOpen(true);
}}></Button>
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
/
</Typography.Paragraph>
<Segmented
style={{ marginBottom: 16 }}
options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
value={category}
onChange={(v) => {
setCategory(String(v));
setPage(1);
const next = new URLSearchParams(searchParams);
if (v) next.set('category', String(v));
else next.delete('category');
setSearchParams(next);
}}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => {
setFilters(v);
setPage(1);
const next = new URLSearchParams(searchParams);
for (const key of ['storeId', 'storeAccountId', 'phone', 'storeName', 'eventName'] as const) {
if (v[key]) next.set(key, v[key]);
else next.delete(key);
}
setSearchParams(next);
}}>
<Form.Item name="storeId" label="门店ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
<Form.Item name="storeName" label="门店名称"><Input allowClear style={{ width: 140 }} /></Form.Item>
<Form.Item name="storeAccountId" label="账号ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
<Form.Item name="eventName" label="事件名"><Input allowClear style={{ width: 160 }} placeholder="store_login_success" /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item>
</Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="商户日志详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="门店">{String(detail.storeName || detail.storeId || '—')}</Descriptions.Item>
<Descriptions.Item label="账号">{String(detail.accountName || detail.storeAccountId || '系统/HQ')}</Descriptions.Item>
<Descriptions.Item label="手机">{String(detail.accountPhone || '—')}</Descriptions.Item>
<Descriptions.Item label="分类">
{(STORE_LOG_CATEGORY_LABELS as Record<string, string>)[String(detail.category ?? '')] || String(detail.category || '—')}
</Descriptions.Item>
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item>
<Descriptions.Item label="来源">{SOURCE_LABELS[String(detail.source) as Row['source']] || String(detail.source || '—')}</Descriptions.Item>
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
<Descriptions.Item label="参数">
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{JSON.stringify(detail.extraJson ?? {}, null, 2)}
</pre>
</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}
+7
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Button,
@@ -61,6 +62,7 @@ type CityOption = {
};
export default function StoresPage() {
const navigate = useNavigate();
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm<StoreCreateForm>();
@@ -321,6 +323,11 @@ export default function StoresPage() {
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
<Descriptions.Item label="操作">
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
</Button>
</Descriptions.Item>
{detail.coverUrl ? (
<Descriptions.Item label="封面">
<Image src={String(detail.coverUrl)} width={120} />