feat:采购日志
This commit is contained in:
@@ -660,6 +660,56 @@ export interface RockTokenItem {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface RockPurchaseResult {
|
||||
purchased_count: number;
|
||||
requested_count: number;
|
||||
planned_count: number;
|
||||
limit_reason: string;
|
||||
log_id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RockPurchaseLog {
|
||||
id: number;
|
||||
trigger_type: "auto" | "manual";
|
||||
status: "running" | "success" | "partial" | "failed" | "skipped";
|
||||
requested_count: number;
|
||||
planned_count: number;
|
||||
claimed_count: number;
|
||||
imported_count: number;
|
||||
upstream_available: number;
|
||||
points_before: number;
|
||||
unit_cost: number;
|
||||
max_by_points: number;
|
||||
max_by_stock: number;
|
||||
limit_reason: string;
|
||||
points_source: string;
|
||||
stage: string;
|
||||
error_message: string;
|
||||
package_id?: number;
|
||||
share_code: string;
|
||||
duration_ms: number;
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RockPurchaseStage {
|
||||
id: number;
|
||||
log_id: number;
|
||||
seq: number;
|
||||
stage: string;
|
||||
method: string;
|
||||
url: string;
|
||||
request_body: string;
|
||||
http_status: number;
|
||||
response_body: string;
|
||||
truncated: boolean;
|
||||
duration_ms: number;
|
||||
error_message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const rockApi = {
|
||||
getConfig: () =>
|
||||
request.get("/platform/rock/config").then((r) => r.data.data as RockConfig),
|
||||
@@ -678,7 +728,25 @@ export const rockApi = {
|
||||
.post("/platform/rock/dashboard/refresh")
|
||||
.then((r) => r.data.data as RockDashboard),
|
||||
purchaseTokens: (count = 50) =>
|
||||
request.post("/platform/rock/purchase", { count }).then((r) => r.data.data),
|
||||
request
|
||||
.post("/platform/rock/purchase", { count })
|
||||
.then((r) => r.data.data as RockPurchaseResult),
|
||||
listPurchaseLogs: (params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
trigger_type?: string;
|
||||
status?: string;
|
||||
}) =>
|
||||
request
|
||||
.get("/platform/rock/purchase-logs", { params })
|
||||
.then((r) => r.data.data as { list: RockPurchaseLog[]; total: number }),
|
||||
getPurchaseLog: (id: number) =>
|
||||
request
|
||||
.get(`/platform/rock/purchase-logs/${id}`)
|
||||
.then(
|
||||
(r) =>
|
||||
r.data.data as { log: RockPurchaseLog; stages: RockPurchaseStage[] },
|
||||
),
|
||||
listTokens: (params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Alert, Button, Form, Modal, Space, message } from 'antd'
|
||||
import { ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons'
|
||||
import { HistoryOutlined, ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons'
|
||||
import { rockApi, type RockConfig, type RockStats, type RockTokenItem } from '../api'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { RockMetricCards } from './platformRock/RockMetricCards'
|
||||
@@ -8,6 +8,7 @@ import { RockStrategyCard } from './platformRock/RockStrategyCard'
|
||||
import { RockTokenFilterBar } from './platformRock/RockTokenFilterBar'
|
||||
import { RockTokenTable } from './platformRock/RockTokenTable'
|
||||
import { PurchaseTokenModal } from './platformRock/PurchaseTokenModal'
|
||||
import { PurchaseLogDrawer } from './platformRock/PurchaseLogDrawer'
|
||||
import { UidEditModal } from './platformRock/UidEditModal'
|
||||
import { VoucherPreviewModal } from './platformRock/VoucherPreviewModal'
|
||||
import type { UidEditState } from './platformRock/types'
|
||||
@@ -33,6 +34,7 @@ export default function PlatformRock() {
|
||||
const [purchasing, setPurchasing] = useState(false)
|
||||
const [purchaseModalOpen, setPurchaseModalOpen] = useState(false)
|
||||
const [purchaseCount, setPurchaseCount] = useState(50)
|
||||
const [purchaseLogOpen, setPurchaseLogOpen] = useState(false)
|
||||
const [rawDataRecord, setRawDataRecord] = useState<RockTokenItem | null>(null)
|
||||
|
||||
// 凭证图片操作状态
|
||||
@@ -209,7 +211,11 @@ export default function PlatformRock() {
|
||||
setPurchasing(true)
|
||||
try {
|
||||
const res = await rockApi.purchaseTokens(count)
|
||||
message.success(res.message || `成功采购 ${count} 个通用黑色 Token!`)
|
||||
if (res.limit_reason) {
|
||||
message.warning(res.message || `上游货源不足,已按 ${res.planned_count} 个采购`)
|
||||
} else {
|
||||
message.success(res.message || `成功采购 ${count} 个通用黑色 Token!`)
|
||||
}
|
||||
setPurchaseModalOpen(false)
|
||||
loadData()
|
||||
loadTokens()
|
||||
@@ -293,6 +299,9 @@ export default function PlatformRock() {
|
||||
<Button icon={<ReloadOutlined />} onClick={loadData} loading={loadingStats}>
|
||||
刷新状态
|
||||
</Button>
|
||||
<Button icon={<HistoryOutlined />} onClick={() => setPurchaseLogOpen(true)}>
|
||||
采购日志
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
@@ -426,6 +435,8 @@ export default function PlatformRock() {
|
||||
previewVoucherUrl={previewVoucherUrl}
|
||||
onClose={() => setPreviewVoucherOpen(false)}
|
||||
/>
|
||||
|
||||
<PurchaseLogDrawer open={purchaseLogOpen} onClose={() => setPurchaseLogOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { Collapse, Drawer, Select, Space, Table, Tag, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { rockApi, type RockPurchaseLog, type RockPurchaseStage } from '../../api'
|
||||
import { formatDateTime } from '../../utils/time'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface PurchaseLogDrawerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const statusMeta: Record<string, { color: string; text: string }> = {
|
||||
running: { color: 'processing', text: '进行中' },
|
||||
success: { color: 'success', text: '成功' },
|
||||
partial: { color: 'warning', text: '部分成交' },
|
||||
failed: { color: 'error', text: '失败' },
|
||||
skipped: { color: 'default', text: '未采购' },
|
||||
}
|
||||
|
||||
const limitText: Record<string, string> = {
|
||||
points: '积分不足',
|
||||
upstream_stock: '上游货源不足',
|
||||
points_and_stock: '积分与货源不足',
|
||||
}
|
||||
|
||||
const stageText: Record<string, string> = {
|
||||
me: '查询积分',
|
||||
dashboard: '查询库存/单价',
|
||||
claim: '提取 Token',
|
||||
package: '创建打包',
|
||||
detail: '拉取包详情',
|
||||
import: '导入本地',
|
||||
}
|
||||
|
||||
function prettyJSON(raw: string): string {
|
||||
if (!raw) return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
export function PurchaseLogDrawer({ open, onClose }: PurchaseLogDrawerProps) {
|
||||
const [logs, setLogs] = useState<RockPurchaseLog[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [triggerType, setTriggerType] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [stageMap, setStageMap] = useState<Record<number, RockPurchaseStage[]>>({})
|
||||
const [stageLoading, setStageLoading] = useState<Record<number, boolean>>({})
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await rockApi.listPurchaseLogs({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
trigger_type: triggerType || undefined,
|
||||
status: status || undefined,
|
||||
})
|
||||
setLogs(res.list || [])
|
||||
setTotal(res.total || 0)
|
||||
} catch (e) {
|
||||
message.error('加载采购日志失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, triggerType, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
load()
|
||||
}
|
||||
}, [open, load])
|
||||
|
||||
const loadStages = async (id: number) => {
|
||||
if (stageMap[id] || stageLoading[id]) return
|
||||
setStageLoading((prev) => ({ ...prev, [id]: true }))
|
||||
try {
|
||||
const res = await rockApi.getPurchaseLog(id)
|
||||
setStageMap((prev) => ({ ...prev, [id]: res.stages || [] }))
|
||||
} catch (e) {
|
||||
message.error('加载阶段详情失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||||
} finally {
|
||||
setStageLoading((prev) => ({ ...prev, [id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<RockPurchaseLog> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70, align: 'right' },
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'started_at',
|
||||
width: 175,
|
||||
render: (value?: string) => (value ? formatDateTime(value) : '-'),
|
||||
},
|
||||
{
|
||||
title: '触发方式',
|
||||
dataIndex: 'trigger_type',
|
||||
width: 90,
|
||||
render: (value: string) =>
|
||||
value === 'auto' ? <Tag color="blue">自动补货</Tag> : <Tag>手动采购</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (value: string) => {
|
||||
const meta = statusMeta[value] || { color: 'default', text: value }
|
||||
return <Tag color={meta.color}>{meta.text}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '计划 → 实际',
|
||||
width: 110,
|
||||
render: (_, record) => (
|
||||
<Text style={{ fontFamily: 'monospace' }}>
|
||||
{record.requested_count} → {record.planned_count}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '入库 / 提取',
|
||||
width: 105,
|
||||
render: (_, record) => (
|
||||
<Text style={{ fontFamily: 'monospace' }}>
|
||||
{record.imported_count} / {record.claimed_count}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '积分 / 单价',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{record.points_before.toFixed(2)} / {record.unit_cost.toFixed(2)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '上游库存',
|
||||
dataIndex: 'upstream_available',
|
||||
width: 85,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
title: '限制原因',
|
||||
dataIndex: 'limit_reason',
|
||||
width: 120,
|
||||
render: (value: string) =>
|
||||
value ? <Tag color="orange">{limitText[value] || value}</Tag> : <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'duration_ms',
|
||||
width: 80,
|
||||
align: 'right',
|
||||
render: (value: number) => `${(value / 1000).toFixed(1)}s`,
|
||||
},
|
||||
{
|
||||
title: '错误',
|
||||
dataIndex: 'error_message',
|
||||
ellipsis: true,
|
||||
render: (value: string) =>
|
||||
value ? (
|
||||
<Text type="danger" style={{ fontSize: 12 }}>
|
||||
{value}
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary">-</Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="采购日志"
|
||||
width={1180}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Select
|
||||
value={triggerType}
|
||||
style={{ width: 140 }}
|
||||
onChange={(value) => {
|
||||
setTriggerType(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: '全部触发方式' },
|
||||
{ value: 'auto', label: '自动补货' },
|
||||
{ value: 'manual', label: '手动采购' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={status}
|
||||
style={{ width: 140 }}
|
||||
onChange={(value) => {
|
||||
setStatus(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'success', label: '成功' },
|
||||
{ value: 'partial', label: '部分成交' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
{ value: 'skipped', label: '未采购' },
|
||||
]}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
共 {total} 条记录,点击行可展开原始响应
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={logs}
|
||||
loading={loading}
|
||||
scroll={{ x: 1320 }}
|
||||
expandable={{
|
||||
expandedRowRender: (record) => {
|
||||
if (stageLoading[record.id]) {
|
||||
return <Text type="secondary">阶段详情加载中…</Text>
|
||||
}
|
||||
const stages = stageMap[record.id]
|
||||
if (!stages || stages.length === 0) {
|
||||
return <Text type="secondary">暂无阶段数据</Text>
|
||||
}
|
||||
return (
|
||||
<Collapse
|
||||
size="small"
|
||||
items={stages.map((stage) => ({
|
||||
key: String(stage.id),
|
||||
label: (
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">{stageText[stage.stage] || stage.stage}</Tag>
|
||||
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{stage.method} {stage.http_status || '-'}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{stage.duration_ms}ms
|
||||
</Text>
|
||||
{stage.truncated ? <Tag color="orange">已截断</Tag> : null}
|
||||
{stage.error_message ? (
|
||||
<Text type="danger" style={{ fontSize: 12 }}>
|
||||
{stage.error_message}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all' }}>
|
||||
{stage.url}
|
||||
</Text>
|
||||
{stage.request_body ? (
|
||||
<>
|
||||
<div style={{ marginTop: 8, fontWeight: 600, fontSize: 12 }}>请求体</div>
|
||||
<pre style={preStyle}>{prettyJSON(stage.request_body)}</pre>
|
||||
</>
|
||||
) : null}
|
||||
<div style={{ marginTop: 8, fontWeight: 600, fontSize: 12 }}>原始响应</div>
|
||||
<pre style={preStyle}>{prettyJSON(stage.response_body) || '(空)'}</pre>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
onExpand: (expanded, record) => {
|
||||
if (expanded) {
|
||||
loadStages(record.id)
|
||||
}
|
||||
},
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (count) => `共 ${count} 条采购记录`,
|
||||
onChange: (nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
const preStyle: CSSProperties = {
|
||||
margin: '6px 0 0',
|
||||
padding: 10,
|
||||
background: '#0f172a',
|
||||
color: '#e2e8f0',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
maxHeight: 320,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}
|
||||
Reference in New Issue
Block a user