feat:采购日志

This commit is contained in:
2026-10-31 12:24:00 +08:00
parent a7f8f9c639
commit 8f30e6fe57
17 changed files with 1241 additions and 32 deletions
+69 -1
View File
@@ -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;
+13 -2
View File
@@ -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',
}