修复 kuaishou-lewan 多账号健康检查与平台配置展示
恢复 health 任务对全部账号的合并监控,补齐 React 后台多账号 UI 与账号选择,并将用户可见文案统一为 kuaishou-lewan。
This commit is contained in:
@@ -170,21 +170,63 @@ function mapScheduledJobCloudtentaclesAccounts(
|
||||
cloudtentaclesAccountMap: Map<string, JsonObject>,
|
||||
) {
|
||||
const accounts = Array.isArray(rawAccounts) ? rawAccounts : []
|
||||
return accounts.map((item) => {
|
||||
const source = isPlainObject(item) ? item : {}
|
||||
const sourceKey = String(source.sourceKey || source.key || '').trim()
|
||||
const option = cloudtentaclesAccountMap.get(sourceKey) || {}
|
||||
const configuredMap = new Map(
|
||||
accounts
|
||||
.map((item) => {
|
||||
const source = isPlainObject(item) ? item : {}
|
||||
const sourceKey = String(source.sourceKey || source.key || '').trim()
|
||||
if (!sourceKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
const option = cloudtentaclesAccountMap.get(sourceKey) || {}
|
||||
return [
|
||||
sourceKey,
|
||||
{
|
||||
sourceKey,
|
||||
label: String(source.label || option.label || sourceKey).trim(),
|
||||
enabled: source.enabled !== false,
|
||||
assetThreshold: normalizeNonNegativeNumber(
|
||||
source.assetThreshold ?? source.threshold,
|
||||
defaultAssetThreshold,
|
||||
),
|
||||
},
|
||||
] as const
|
||||
})
|
||||
.filter(Boolean) as Array<readonly [string, {
|
||||
sourceKey: string
|
||||
label: string
|
||||
enabled: boolean
|
||||
assetThreshold: number
|
||||
}]>,
|
||||
)
|
||||
|
||||
// 读配置时合并全部 kuaishou-lewan 账号,避免前端/任务只看到历史 default。
|
||||
const merged = Array.from(cloudtentaclesAccountMap.values()).map((option) => {
|
||||
const sourceKey = String(option.sourceKey || '').trim()
|
||||
const configured = configuredMap.get(sourceKey)
|
||||
return {
|
||||
sourceKey,
|
||||
label: String(source.label || option.label || sourceKey).trim(),
|
||||
enabled: source.enabled !== false,
|
||||
label: String(configured?.label || option.label || sourceKey).trim(),
|
||||
enabled: configured
|
||||
? configured.enabled !== false
|
||||
: option.enabled !== false,
|
||||
assetThreshold: normalizeNonNegativeNumber(
|
||||
source.assetThreshold ?? source.threshold,
|
||||
configured?.assetThreshold,
|
||||
defaultAssetThreshold,
|
||||
),
|
||||
}
|
||||
}).filter((item) => item.sourceKey)
|
||||
|
||||
const mergedKeys = new Set(merged.map((item) => item.sourceKey))
|
||||
for (const [sourceKey, configured] of configuredMap.entries()) {
|
||||
if (mergedKeys.has(sourceKey)) {
|
||||
continue
|
||||
}
|
||||
merged.push(configured)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function normalizeNonNegativeNumber(value: unknown, fallback: number) {
|
||||
|
||||
@@ -191,23 +191,64 @@ function resolveCloudtentaclesHealthAccounts(job: JsonObject) {
|
||||
const rawAccounts = Array.isArray(config.accounts) ? config.accounts : []
|
||||
const defaultThreshold = normalizeNonNegativeInteger(config.assetThreshold, 500)
|
||||
const sourceConfig = listCloudtentaclesSources()
|
||||
const sources = Array.isArray(sourceConfig.sources) ? sourceConfig.sources : []
|
||||
const sourceMap = new Map(
|
||||
(Array.isArray(sourceConfig.sources) ? sourceConfig.sources : [])
|
||||
.map((source) => [String(source.key || '').trim(), source]),
|
||||
sources
|
||||
.map((source) => [String(source.key || '').trim(), source] as const)
|
||||
.filter(([sourceKey]) => Boolean(sourceKey)),
|
||||
)
|
||||
const normalizedAccounts = rawAccounts
|
||||
.map((item) => normalizeCloudtentaclesHealthAccount(item, defaultThreshold, sourceMap))
|
||||
const configuredMap = new Map(
|
||||
rawAccounts
|
||||
.map((item) => normalizeCloudtentaclesHealthAccount(item, defaultThreshold, sourceMap))
|
||||
.filter(Boolean)
|
||||
.map((item) => [String(item!.sourceKey || '').trim(), item!] as const)
|
||||
.filter(([sourceKey]) => Boolean(sourceKey)),
|
||||
)
|
||||
|
||||
// 以平台配置中的全部账号为准,合并任务里已保存的阈值/开关;新增账号自动纳入监控。
|
||||
const mergedAccounts = sources
|
||||
.map((source) => {
|
||||
const sourceKey = String(source.key || '').trim()
|
||||
if (!sourceKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
const configured = configuredMap.get(sourceKey)
|
||||
return {
|
||||
sourceKey,
|
||||
label: String(
|
||||
configured?.label || source.label || source.username || sourceKey,
|
||||
).trim() || sourceKey,
|
||||
enabled: configured
|
||||
? configured.enabled !== false
|
||||
: source.enabled !== false,
|
||||
assetThreshold: normalizeNonNegativeInteger(
|
||||
configured?.assetThreshold,
|
||||
defaultThreshold,
|
||||
),
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as JsonObject[]
|
||||
|
||||
if (normalizedAccounts.length > 0) {
|
||||
return dedupeAccounts(normalizedAccounts)
|
||||
// 保留已配置但源账号已删除的项,便于报 source_missing。
|
||||
for (const [sourceKey, configured] of configuredMap.entries()) {
|
||||
if (sourceMap.has(sourceKey)) {
|
||||
continue
|
||||
}
|
||||
mergedAccounts.push(configured)
|
||||
}
|
||||
|
||||
if (mergedAccounts.length > 0) {
|
||||
return dedupeAccounts(mergedAccounts)
|
||||
}
|
||||
|
||||
// 兼容旧配置:没有任何平台账号时,仍检查历史 default。
|
||||
const fallback = configuredMap.get('default')
|
||||
return [{
|
||||
sourceKey: 'default',
|
||||
label: sourceMap.get('default')?.label || '默认账号',
|
||||
enabled: true,
|
||||
assetThreshold: defaultThreshold,
|
||||
label: String(fallback?.label || '默认账号').trim() || '默认账号',
|
||||
enabled: fallback ? fallback.enabled !== false : true,
|
||||
assetThreshold: normalizeNonNegativeInteger(fallback?.assetThreshold, defaultThreshold),
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -249,7 +290,7 @@ function dedupeAccounts(accounts: JsonObject[]) {
|
||||
|
||||
function buildCloudtentaclesHealthSummary(results: CloudtentaclesHealthAccountResult[]) {
|
||||
if (results.length === 0) {
|
||||
return '未配置 cloudtentacles 监控账号'
|
||||
return '未配置 kuaishou-lewan 监控账号'
|
||||
}
|
||||
|
||||
const checkedResults = results.filter((item) => !item.skipped)
|
||||
|
||||
@@ -191,7 +191,7 @@ export default function AdminCloudtentaclesRecordsPage() {
|
||||
setSourceKey(nextSourceKey)
|
||||
return nextSourceKey
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '读取 cloudtentacles 账号失败')
|
||||
setErrorMessage(error instanceof Error ? error.message : '读取 kuaishou-lewan 账号失败')
|
||||
return ''
|
||||
} finally {
|
||||
setSourceLoading(false)
|
||||
@@ -213,7 +213,7 @@ export default function AdminCloudtentaclesRecordsPage() {
|
||||
const orderKeyword = (options.platformOrderId ?? platformOrderId).trim()
|
||||
|
||||
if (!nextSourceKey && !orderKeyword) {
|
||||
showError('请先选择 cloudtentacles 账号')
|
||||
showError('请先选择 kuaishou-lewan 账号')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ export default function AdminCloudtentaclesRecordsPage() {
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="查询发货记录"
|
||||
description="按订单号、cloudtentacles 账号和时间范围查询平台发货记录。"
|
||||
description="按订单号、kuaishou-lewan 账号和时间范围查询平台发货记录。"
|
||||
extra={<span className="total-badge">共 {total} 条记录</span>}
|
||||
/>
|
||||
|
||||
@@ -299,7 +299,7 @@ export default function AdminCloudtentaclesRecordsPage() {
|
||||
title="查询条件"
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
输入订单号时会先关联本地任务,再匹配 cloudtentacles 发货记录。
|
||||
输入订单号时会先关联本地任务,再匹配 kuaishou-lewan 发货记录。
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
@@ -307,7 +307,7 @@ export default function AdminCloudtentaclesRecordsPage() {
|
||||
<Select
|
||||
value={sourceKey || undefined}
|
||||
loading={sourceLoading}
|
||||
placeholder="cloudtentacles 账号"
|
||||
placeholder="kuaishou-lewan 账号"
|
||||
options={sourceOptions.map((source) => ({
|
||||
label: `${source.label || source.key}${source.hasToken ? '' : ' · 未登录'}`,
|
||||
value: source.key,
|
||||
|
||||
@@ -430,7 +430,7 @@ export default function AdminTaskDetailPage() {
|
||||
'refresh-role',
|
||||
() => refreshAdminTaskKuaishouCloudRoleInfo(resolvedDetail.task.taskId),
|
||||
'角色信息已刷新',
|
||||
`确认刷新任务 ${resolvedDetail.task.taskNo} 当前云绑定角色信息吗?系统会重新查询 cloudtentacles 的绑定结果。`,
|
||||
`确认刷新任务 ${resolvedDetail.task.taskNo} 当前云绑定角色信息吗?系统会重新查询 kuaishou-lewan 的绑定结果。`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -768,7 +768,7 @@ function KuaishouCloudFulfillmentPanel() {
|
||||
)
|
||||
setSelectedSourceKey(resolveDefaultSourceKey(nextRows, selectedSourceKey))
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '读取 cloudtentacles 履约配置失败')
|
||||
setErrorMessage(error instanceof Error ? error.message : '读取 kuaishou-lewan 履约配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -788,7 +788,7 @@ function KuaishouCloudFulfillmentPanel() {
|
||||
setSkuItems(Array.isArray(response.data.items) ? response.data.items : [])
|
||||
} catch (error) {
|
||||
setSkuItems([])
|
||||
setSkuErrorMessage(error instanceof Error ? error.message : '读取 cloudtentacles 商品失败')
|
||||
setSkuErrorMessage(error instanceof Error ? error.message : '读取 kuaishou-lewan 商品失败')
|
||||
} finally {
|
||||
setSkuLoading(false)
|
||||
}
|
||||
@@ -818,7 +818,7 @@ function KuaishouCloudFulfillmentPanel() {
|
||||
)
|
||||
showSuccess(`商品覆盖规则已保存,共 ${response.data.rules.length} 条`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '保存 cloudtentacles 覆盖规则失败'
|
||||
const message = error instanceof Error ? error.message : '保存 kuaishou-lewan 覆盖规则失败'
|
||||
setOverrideErrorMessage(message)
|
||||
showError(message)
|
||||
} finally {
|
||||
@@ -923,7 +923,7 @@ function KuaishouCloudFulfillmentPanel() {
|
||||
</div>
|
||||
|
||||
<div className="platform-split-layout">
|
||||
<Card title="cloudtentacles 账号" className="platform-side-card">
|
||||
<Card title="kuaishou-lewan 账号" className="platform-side-card">
|
||||
{sourceRows.length === 0 ? (
|
||||
<Empty description="暂无账号,请先到平台配置登录。" />
|
||||
) : (
|
||||
@@ -980,7 +980,7 @@ function KuaishouCloudFulfillmentPanel() {
|
||||
<Input
|
||||
allowClear
|
||||
value={matchInput}
|
||||
placeholder="输入 91 卡券 productNo / 商品名字,查看命中的 cloudtentacles 商品"
|
||||
placeholder="输入 91 卡券 productNo / 商品名字,查看命中的 kuaishou-lewan 商品"
|
||||
onChange={(event) => setMatchInput(event.target.value)}
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} onClick={addOverrideRule}>
|
||||
@@ -1084,7 +1084,7 @@ function KuaishouCloudFulfillmentPanel() {
|
||||
<Select
|
||||
showSearch
|
||||
value={item.cloudSkuId || undefined}
|
||||
placeholder="选择 cloudtentacles 商品"
|
||||
placeholder="选择 kuaishou-lewan 商品"
|
||||
optionFilterProp="label"
|
||||
options={skuItems.map((sku) => ({
|
||||
label: formatSkuOption(sku),
|
||||
@@ -1094,7 +1094,7 @@ function KuaishouCloudFulfillmentPanel() {
|
||||
/>
|
||||
<Input
|
||||
value={item.cloudSkuName}
|
||||
placeholder="cloudtentacles 商品名"
|
||||
placeholder="kuaishou-lewan 商品名"
|
||||
onChange={(event) =>
|
||||
updateDeliveryItem(ruleIndex, itemIndex, {
|
||||
cloudSkuName: event.target.value,
|
||||
@@ -1129,14 +1129,14 @@ function KuaishouCloudFulfillmentPanel() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="cloudtentacles 商品列表">
|
||||
<Card title="kuaishou-lewan 商品列表">
|
||||
<Table<AdminCloudtentaclesSkuItem>
|
||||
rowKey="id"
|
||||
loading={skuLoading}
|
||||
dataSource={skuItems}
|
||||
columns={[
|
||||
{
|
||||
title: 'cloudtentacles 商品',
|
||||
title: 'kuaishou-lewan 商品',
|
||||
dataIndex: 'name',
|
||||
minWidth: 260,
|
||||
render: (_, row) => (
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
validateAdminCloudtentaclesSession,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminCloudtentaclesMonitorAccount,
|
||||
AdminCloudtentaclesSourceConfigResponse,
|
||||
AdminCloudtentaclesSourceItem,
|
||||
AdminKuaishouIndustryConfigResponse,
|
||||
@@ -72,6 +73,8 @@ import type {
|
||||
AdminNinetyoneOrderListResult,
|
||||
AdminNotificationConfig,
|
||||
AdminNotificationTestResult,
|
||||
AdminScheduledJobAccountRuntime,
|
||||
AdminScheduledJobCloudtentaclesAccount,
|
||||
AdminScheduledJobItem,
|
||||
AdminScheduledJobRuntimeState,
|
||||
AdminScheduledJobsConfig,
|
||||
@@ -100,15 +103,7 @@ type ScheduledJobsState = {
|
||||
filePath: string
|
||||
source: AdminScheduledJobsConfig
|
||||
runtime: AdminScheduledJobRuntimeState[]
|
||||
cloudtentaclesAccounts: Array<{
|
||||
sourceKey: string
|
||||
label: string
|
||||
enabled: boolean
|
||||
username: string
|
||||
phoneMasked: string
|
||||
hasToken: boolean
|
||||
loggedInAt: string
|
||||
}>
|
||||
cloudtentaclesAccounts: AdminCloudtentaclesMonitorAccount[]
|
||||
}
|
||||
|
||||
const platformTabs: PlatformTab[] = [
|
||||
@@ -259,14 +254,14 @@ export default function AdminPlatformShopsPage() {
|
||||
},
|
||||
{
|
||||
key: 'cloudtentacles',
|
||||
label: 'cloudtentacles',
|
||||
label: 'kuaishou-lewan',
|
||||
children: cloudtentaclesConfig ? (
|
||||
<CloudtentaclesPlatformPanel
|
||||
config={cloudtentaclesConfig}
|
||||
onChange={setCloudtentaclesConfig}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="cloudtentacles 配置未加载" />
|
||||
<Empty description="kuaishou-lewan 配置未加载" />
|
||||
),
|
||||
},
|
||||
]}
|
||||
@@ -474,6 +469,18 @@ function NotificationPanel({
|
||||
const [runningJobId, setRunningJobId] = useState('')
|
||||
const [testResult, setTestResult] = useState<AdminNotificationTestResult | null>(null)
|
||||
const source = notificationConfig.source
|
||||
const monitorAccounts = scheduledJobs.cloudtentaclesAccounts || []
|
||||
const jobsWithMergedAccounts = scheduledJobs.source.jobs.map((job) => ({
|
||||
...job,
|
||||
config: {
|
||||
...job.config,
|
||||
accounts: mergeScheduledJobAccounts(
|
||||
job.config?.accounts || [],
|
||||
monitorAccounts,
|
||||
Number(job.config?.assetThreshold ?? 500),
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
async function saveNotification() {
|
||||
setSavingNotification(true)
|
||||
@@ -507,7 +514,22 @@ function NotificationPanel({
|
||||
async function saveJobs() {
|
||||
setSavingJobs(true)
|
||||
try {
|
||||
const response = await saveAdminScheduledJobsConfig(scheduledJobs.source)
|
||||
const payload: AdminScheduledJobsConfig = {
|
||||
...scheduledJobs.source,
|
||||
jobs: jobsWithMergedAccounts.map((job) => ({
|
||||
...job,
|
||||
config: {
|
||||
assetThreshold: Number(job.config?.assetThreshold ?? 500),
|
||||
accounts: (job.config?.accounts || []).map((account) => ({
|
||||
sourceKey: account.sourceKey,
|
||||
label: account.label,
|
||||
enabled: account.enabled !== false,
|
||||
assetThreshold: Number(account.assetThreshold ?? job.config?.assetThreshold ?? 500),
|
||||
})),
|
||||
},
|
||||
})),
|
||||
}
|
||||
const response = await saveAdminScheduledJobsConfig(payload)
|
||||
onScheduledJobsChange(response.data)
|
||||
showSuccess('定时任务配置已保存')
|
||||
} catch (error) {
|
||||
@@ -541,6 +563,15 @@ function NotificationPanel({
|
||||
onScheduledJobsChange({ ...scheduledJobs, source: nextSource })
|
||||
}
|
||||
|
||||
function updateJob(index: number, nextJob: AdminScheduledJobItem) {
|
||||
updateJobs({
|
||||
...scheduledJobs.source,
|
||||
jobs: jobsWithMergedAccounts.map((item, jobIndex) =>
|
||||
jobIndex === index ? nextJob : item,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="platform-panel-stack">
|
||||
<Card
|
||||
@@ -649,7 +680,7 @@ function NotificationPanel({
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="定时任务"
|
||||
title="监控任务"
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Typography.Text type="secondary">{scheduledJobs.filePath || '默认配置'}</Typography.Text>
|
||||
@@ -659,6 +690,9 @@ function NotificationPanel({
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
按 kuaishou-lewan 账号分别检查登录态和余额;新增账号会自动纳入监控,可单独设置阈值。
|
||||
</Typography.Paragraph>
|
||||
<FieldSwitch
|
||||
label="任务总开关"
|
||||
checked={scheduledJobs.source.enabled}
|
||||
@@ -666,21 +700,15 @@ function NotificationPanel({
|
||||
/>
|
||||
|
||||
<Space direction="vertical" className="full-width platform-section-gap" size={12}>
|
||||
{scheduledJobs.source.jobs.map((job, index) => (
|
||||
{jobsWithMergedAccounts.map((job, index) => (
|
||||
<ScheduledJobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
runtime={scheduledJobs.runtime.find((item) => item.id === job.id) || null}
|
||||
monitorAccounts={monitorAccounts}
|
||||
running={runningJobId === job.id}
|
||||
onRun={() => runJob(job.id)}
|
||||
onChange={(nextJob) =>
|
||||
updateJobs({
|
||||
...scheduledJobs.source,
|
||||
jobs: scheduledJobs.source.jobs.map((item, jobIndex) =>
|
||||
jobIndex === index ? nextJob : item,
|
||||
),
|
||||
})
|
||||
}
|
||||
onChange={(nextJob) => updateJob(index, nextJob)}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
@@ -754,26 +782,138 @@ function RecipientList<T extends { id: string; name: string; enabled: boolean }>
|
||||
function ScheduledJobCard({
|
||||
job,
|
||||
runtime,
|
||||
monitorAccounts,
|
||||
running,
|
||||
onRun,
|
||||
onChange,
|
||||
}: {
|
||||
job: AdminScheduledJobItem
|
||||
runtime: AdminScheduledJobRuntimeState | null
|
||||
monitorAccounts: AdminCloudtentaclesMonitorAccount[]
|
||||
running: boolean
|
||||
onRun: () => void
|
||||
onChange: (job: AdminScheduledJobItem) => void
|
||||
}) {
|
||||
const accounts = job.config?.accounts || []
|
||||
const defaultThreshold = Number(job.config?.assetThreshold ?? 500)
|
||||
|
||||
function updateAccount(sourceKey: string, patch: Partial<AdminScheduledJobCloudtentaclesAccount>) {
|
||||
onChange({
|
||||
...job,
|
||||
config: {
|
||||
...job.config,
|
||||
accounts: accounts.map((account) =>
|
||||
account.sourceKey === sourceKey ? { ...account, ...patch } : account,
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function updateDefaultThreshold(assetThreshold: number) {
|
||||
onChange({
|
||||
...job,
|
||||
config: {
|
||||
...job.config,
|
||||
assetThreshold,
|
||||
accounts: accounts.map((account) => ({
|
||||
...account,
|
||||
// 仅同步仍等于旧默认阈值的账号,避免覆盖用户单独设置
|
||||
assetThreshold:
|
||||
Number(account.assetThreshold) === defaultThreshold
|
||||
? assetThreshold
|
||||
: account.assetThreshold,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const accountColumns: TableColumnsType<AdminScheduledJobCloudtentaclesAccount> = [
|
||||
{
|
||||
title: '账号',
|
||||
minWidth: 220,
|
||||
render: (_, account) => {
|
||||
const source = monitorAccounts.find((item) => item.sourceKey === account.sourceKey)
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text strong>{account.label || account.sourceKey}</Typography.Text>
|
||||
<Typography.Text type="secondary">{account.sourceKey}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{formatMonitorAccountMeta(account, source)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '监控',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (_, account) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={account.enabled !== false}
|
||||
onChange={(enabled) => updateAccount(account.sourceKey, { enabled })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '余额阈值',
|
||||
width: 140,
|
||||
render: (_, account) => (
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={account.assetThreshold}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) =>
|
||||
updateAccount(account.sourceKey, {
|
||||
assetThreshold: Number(value ?? defaultThreshold),
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '最近结果',
|
||||
minWidth: 240,
|
||||
render: (_, account) => {
|
||||
const accountRuntime = getAccountRuntime(runtime, account.sourceKey)
|
||||
if (!accountRuntime) {
|
||||
return <Typography.Text type="secondary">尚未检查</Typography.Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Space wrap size={6}>
|
||||
<Tag color={resolveHealthStatusColor(accountRuntime.status)}>
|
||||
{formatHealthStatus(accountRuntime.status)}
|
||||
</Tag>
|
||||
<Typography.Text type="secondary">
|
||||
余额 {accountRuntime.asset ?? '-'} / 阈值 {accountRuntime.threshold ?? account.assetThreshold}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Text type="secondary">
|
||||
{accountRuntime.message || formatHealthStatus(accountRuntime.status)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{formatAdminDateTime(accountRuntime.checkedAt)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<Space wrap>
|
||||
<Switch checked={job.enabled !== false} onChange={(enabled) => onChange({ ...job, enabled })} />
|
||||
<span>{job.id}</span>
|
||||
<span>{formatScheduledJobTitle(job)}</span>
|
||||
<Tag color={job.enabled ? 'green' : 'default'}>{job.enabled ? '已启用' : '已停用'}</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={<Button icon={<ReloadOutlined />} loading={running} onClick={onRun}>立即运行</Button>}
|
||||
extra={<Button icon={<ReloadOutlined />} loading={running} onClick={onRun}>立即检查</Button>}
|
||||
>
|
||||
<div className="platform-form-grid">
|
||||
<NumberField
|
||||
@@ -789,28 +929,148 @@ function ScheduledJobCard({
|
||||
onChange={(cooldownSeconds) => onChange({ ...job, cooldownSeconds })}
|
||||
/>
|
||||
<NumberField
|
||||
label="资产阈值"
|
||||
value={job.config.assetThreshold}
|
||||
label="默认余额阈值"
|
||||
value={defaultThreshold}
|
||||
min={0}
|
||||
onChange={(assetThreshold) =>
|
||||
onChange({ ...job, config: { ...job.config, assetThreshold } })
|
||||
}
|
||||
onChange={(assetThreshold) => updateDefaultThreshold(Number(assetThreshold ?? 500))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{runtime ? (
|
||||
<Alert
|
||||
className="platform-section-gap"
|
||||
type={runtime.lastStatus === 'success' ? 'success' : runtime.lastStatus ? 'warning' : 'info'}
|
||||
type={
|
||||
runtime.lastStatus === 'ok' || runtime.lastStatus === 'success'
|
||||
? 'success'
|
||||
: runtime.lastStatus
|
||||
? 'warning'
|
||||
: 'info'
|
||||
}
|
||||
showIcon
|
||||
message={runtime.lastMessage || '尚未运行'}
|
||||
description={`上次完成:${formatAdminDateTime(runtime.lastFinishedAt)} · 下次:${formatAdminDateTime(runtime.nextRunAt)}`}
|
||||
description={
|
||||
[
|
||||
`已检查 ${runtime.lastCheckedCount ?? 0} / ${accounts.length}`,
|
||||
`预警 ${runtime.lastLowAssetCount ?? 0}`,
|
||||
`异常 ${runtime.lastFailedCount ?? 0}`,
|
||||
`上次:${formatAdminDateTime(runtime.lastFinishedAt || runtime.lastRunAt)}`,
|
||||
`下次:${formatAdminDateTime(runtime.nextRunAt)}`,
|
||||
].join(' · ')
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Table<AdminScheduledJobCloudtentaclesAccount>
|
||||
className="platform-section-gap"
|
||||
rowKey="sourceKey"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={accountColumns}
|
||||
dataSource={accounts}
|
||||
locale={{ emptyText: '暂无 kuaishou-lewan 账号,请先在平台配置登录。' }}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function mergeScheduledJobAccounts(
|
||||
configuredAccounts: AdminScheduledJobCloudtentaclesAccount[],
|
||||
monitorAccounts: AdminCloudtentaclesMonitorAccount[],
|
||||
defaultAssetThreshold: number,
|
||||
) {
|
||||
const configuredMap = new Map(
|
||||
configuredAccounts
|
||||
.map((item) => [String(item.sourceKey || '').trim(), item] as const)
|
||||
.filter(([sourceKey]) => Boolean(sourceKey)),
|
||||
)
|
||||
const merged = monitorAccounts
|
||||
.filter((item) => String(item.sourceKey || '').trim())
|
||||
.map((item) => {
|
||||
const sourceKey = String(item.sourceKey || '').trim()
|
||||
const configured = configuredMap.get(sourceKey)
|
||||
return {
|
||||
sourceKey,
|
||||
label: String(configured?.label || item.label || sourceKey).trim(),
|
||||
enabled: configured ? configured.enabled !== false : item.enabled !== false,
|
||||
assetThreshold: Number(
|
||||
configured?.assetThreshold ?? defaultAssetThreshold,
|
||||
),
|
||||
}
|
||||
})
|
||||
const mergedKeys = new Set(merged.map((item) => item.sourceKey))
|
||||
|
||||
for (const configured of configuredAccounts) {
|
||||
const sourceKey = String(configured.sourceKey || '').trim()
|
||||
if (!sourceKey || mergedKeys.has(sourceKey)) {
|
||||
continue
|
||||
}
|
||||
merged.push({
|
||||
sourceKey,
|
||||
label: String(configured.label || sourceKey).trim(),
|
||||
enabled: configured.enabled !== false,
|
||||
assetThreshold: Number(configured.assetThreshold ?? defaultAssetThreshold),
|
||||
})
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function formatScheduledJobTitle(job: AdminScheduledJobItem) {
|
||||
if (job.type === 'cloudtentacles_health' || job.id === 'cloudtentacles-health') {
|
||||
return 'kuaishou-lewan 健康检查'
|
||||
}
|
||||
return job.id || job.type || '定时任务'
|
||||
}
|
||||
|
||||
function formatMonitorAccountMeta(
|
||||
account: AdminScheduledJobCloudtentaclesAccount,
|
||||
source: AdminCloudtentaclesMonitorAccount | undefined,
|
||||
) {
|
||||
if (!source) {
|
||||
return '账号配置不存在'
|
||||
}
|
||||
|
||||
return [
|
||||
source.username ? `登录名 ${source.username}` : '',
|
||||
source.phoneMasked ? `手机 ${source.phoneMasked}` : '',
|
||||
source.hasToken ? 'Token 已保存' : '未登录',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || account.sourceKey
|
||||
}
|
||||
|
||||
function getAccountRuntime(
|
||||
runtime: AdminScheduledJobRuntimeState | null,
|
||||
sourceKey: string,
|
||||
): AdminScheduledJobAccountRuntime | null {
|
||||
if (!runtime?.lastAccounts?.length) {
|
||||
return null
|
||||
}
|
||||
return runtime.lastAccounts.find((item) => item.sourceKey === sourceKey) || null
|
||||
}
|
||||
|
||||
function formatHealthStatus(status: string) {
|
||||
const normalized = String(status || '').trim()
|
||||
if (normalized === 'ok' || normalized === 'success') return '正常'
|
||||
if (normalized === 'asset_low') return '余额预警'
|
||||
if (normalized === 'auth_missing') return '未登录'
|
||||
if (normalized === 'source_missing') return '账号缺失'
|
||||
if (normalized === 'disabled') return '已停用'
|
||||
if (normalized === 'skipped') return '已跳过'
|
||||
if (normalized === 'running') return '执行中'
|
||||
if (normalized === 'failed') return '检查失败'
|
||||
if (normalized === 'pending') return '待执行'
|
||||
return normalized || '待执行'
|
||||
}
|
||||
|
||||
function resolveHealthStatusColor(status: string) {
|
||||
const normalized = String(status || '').trim()
|
||||
if (normalized === 'ok' || normalized === 'success') return 'green'
|
||||
if (normalized === 'asset_low' || normalized === 'running') return 'orange'
|
||||
if (['auth_missing', 'source_missing', 'failed'].includes(normalized)) return 'red'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function KuaishouIndustryPanel({
|
||||
config,
|
||||
onChange,
|
||||
@@ -1529,7 +1789,14 @@ function CloudtentaclesPlatformPanel({
|
||||
const [selectedSourceKey, setSelectedSourceKey] = useState(config.sources[0]?.key || '')
|
||||
const [smsCode, setSmsCode] = useState('')
|
||||
const [debugResult, setDebugResult] = useState<unknown>(null)
|
||||
const selectedSource = config.sources.find((source) => source.key === selectedSourceKey) || config.sources[0] || null
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.sources.some((source) => source.key === selectedSourceKey)) {
|
||||
setSelectedSourceKey(config.sources[0]?.key || '')
|
||||
}
|
||||
}, [config.sources, selectedSourceKey])
|
||||
|
||||
const selectedSource = config.sources.find((source) => source.key === selectedSourceKey) || null
|
||||
const selectedSession = selectedSource ? config.sessions[selectedSource.key] : null
|
||||
|
||||
async function saveConfig() {
|
||||
@@ -1540,9 +1807,13 @@ function CloudtentaclesPlatformPanel({
|
||||
sources: config.sources,
|
||||
})
|
||||
onChange(response.data)
|
||||
showSuccess('cloudtentacles 账号配置已保存')
|
||||
const nextKey = selectedSourceKey && response.data.sources.some((item) => item.key === selectedSourceKey)
|
||||
? selectedSourceKey
|
||||
: response.data.sources[0]?.key || ''
|
||||
setSelectedSourceKey(nextKey)
|
||||
showSuccess('kuaishou-lewan 账号配置已保存')
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '保存 cloudtentacles 账号失败')
|
||||
showError(error instanceof Error ? error.message : '保存 kuaishou-lewan 账号失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -1554,24 +1825,35 @@ function CloudtentaclesPlatformPanel({
|
||||
await deleteAdminCloudtentaclesSource(sourceKey)
|
||||
const response = await fetchAdminCloudtentaclesSourceConfig()
|
||||
onChange(response.data)
|
||||
setSelectedSourceKey(response.data.sources[0]?.key || '')
|
||||
showSuccess('cloudtentacles 账号已删除')
|
||||
const nextKey = selectedSourceKey === sourceKey
|
||||
? response.data.sources[0]?.key || ''
|
||||
: selectedSourceKey
|
||||
setSelectedSourceKey(
|
||||
response.data.sources.some((item) => item.key === nextKey)
|
||||
? nextKey
|
||||
: response.data.sources[0]?.key || '',
|
||||
)
|
||||
showSuccess('kuaishou-lewan 账号已删除')
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '删除 cloudtentacles 账号失败')
|
||||
showError(error instanceof Error ? error.message : '删除 kuaishou-lewan 账号失败')
|
||||
} finally {
|
||||
setDebugLoading('')
|
||||
}
|
||||
}
|
||||
|
||||
async function runDebug(action: string, handler: () => Promise<unknown>) {
|
||||
if (!selectedSource) {
|
||||
showError('请先选择一个账号')
|
||||
return
|
||||
}
|
||||
setDebugLoading(action)
|
||||
setDebugResult(null)
|
||||
try {
|
||||
const result = await handler()
|
||||
setDebugResult(result)
|
||||
showSuccess('cloudtentacles 调试完成')
|
||||
showSuccess(`账号 ${formatCloudtentaclesSourceLabel(selectedSource)} 调试完成`)
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : 'cloudtentacles 调试失败')
|
||||
showError(error instanceof Error ? error.message : 'kuaishou-lewan 调试失败')
|
||||
} finally {
|
||||
setDebugLoading('')
|
||||
}
|
||||
@@ -1587,6 +1869,9 @@ function CloudtentaclesPlatformPanel({
|
||||
source.key === sourceKey ? { ...source, ...patch } : source,
|
||||
),
|
||||
})
|
||||
if (patch.key && patch.key !== sourceKey && selectedSourceKey === sourceKey) {
|
||||
setSelectedSourceKey(String(patch.key))
|
||||
}
|
||||
}
|
||||
|
||||
function addSource() {
|
||||
@@ -1613,7 +1898,7 @@ function CloudtentaclesPlatformPanel({
|
||||
return (
|
||||
<section className="platform-panel-stack">
|
||||
<Card
|
||||
title="cloudtentacles 履约账号"
|
||||
title="kuaishou-lewan 履约账号"
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Typography.Text type="secondary">{config.filePath || '默认配置'}</Typography.Text>
|
||||
@@ -1624,44 +1909,114 @@ function CloudtentaclesPlatformPanel({
|
||||
}
|
||||
>
|
||||
{config.sources.length === 0 ? (
|
||||
<Empty description="暂无 cloudtentacles 账号" />
|
||||
<Empty description="暂无 kuaishou-lewan 账号" />
|
||||
) : (
|
||||
<Space direction="vertical" className="full-width" size={12}>
|
||||
{config.sources.map((source) => {
|
||||
const session = config.sessions[source.key]
|
||||
const active = source.key === selectedSourceKey
|
||||
return (
|
||||
<Card
|
||||
key={source.key}
|
||||
size="small"
|
||||
className={active ? 'platform-source-card active' : 'platform-source-card'}
|
||||
title={
|
||||
<Space>
|
||||
<Switch checked={source.enabled !== false} onChange={(enabled) => updateSource(source.key, { enabled })} />
|
||||
<Button type="link" onClick={() => setSelectedSourceKey(source.key)}>
|
||||
{source.label || source.username || source.key}
|
||||
</Button>
|
||||
<Tag color={session?.hasToken ? 'green' : 'orange'}>
|
||||
{session?.hasToken ? '已登录' : '未登录'}
|
||||
<div className="platform-split-layout">
|
||||
<Card title="账号列表" size="small" className="platform-side-card">
|
||||
<Space direction="vertical" className="full-width" size={8}>
|
||||
{config.sources.map((source) => {
|
||||
const session = config.sessions[source.key]
|
||||
const active = source.key === selectedSourceKey
|
||||
return (
|
||||
<button
|
||||
key={source.key}
|
||||
type="button"
|
||||
className={active ? 'platform-select-card active' : 'platform-select-card'}
|
||||
onClick={() => setSelectedSourceKey(source.key)}
|
||||
>
|
||||
<span>
|
||||
<strong>{formatCloudtentaclesSourceLabel(source)}</strong>
|
||||
<small>{source.username || source.key}</small>
|
||||
</span>
|
||||
<Tag color={session?.hasToken ? 'green' : source.enabled === false ? 'default' : 'orange'}>
|
||||
{session?.hasToken ? '已登录' : source.enabled === false ? '已停用' : '未登录'}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={<Button danger icon={<DeleteOutlined />} loading={debugLoading === `delete-${source.key}`} onClick={() => deleteSource(source.key)}>删除</Button>}
|
||||
>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title={selectedSource ? `编辑账号:${formatCloudtentaclesSourceLabel(selectedSource)}` : '编辑账号'}
|
||||
extra={
|
||||
selectedSource ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={debugLoading === `delete-${selectedSource.key}`}
|
||||
onClick={() => deleteSource(selectedSource.key)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{!selectedSource ? (
|
||||
<Empty description="请选择左侧账号" />
|
||||
) : (
|
||||
<>
|
||||
<div className="platform-form-grid">
|
||||
<LabeledInput label="账号标识" value={source.key} onChange={(value) => updateSource(source.key, { key: value })} />
|
||||
<LabeledInput label="显示名称" value={source.label} onChange={(value) => updateSource(source.key, { label: value })} />
|
||||
<LabeledInput label="接口地址" value={source.baseUrl} onChange={(value) => updateSource(source.key, { baseUrl: value })} />
|
||||
<LabeledInput label="用户名" value={source.username} onChange={(value) => updateSource(source.key, { username: value })} />
|
||||
<LabeledInput label="密码" password value={source.password} onChange={(value) => updateSource(source.key, { password: value })} />
|
||||
<LabeledInput label="手机号" value={source.phone} onChange={(value) => updateSource(source.key, { phone: value })} />
|
||||
<LabeledInput label="设备 ID" value={source.deviceId} onChange={(value) => updateSource(source.key, { deviceId: value })} />
|
||||
<NumberField label="设备类型" value={source.deviceType} min={0} onChange={(deviceType) => updateSource(source.key, { deviceType })} />
|
||||
<FieldSwitch
|
||||
label="启用账号"
|
||||
checked={selectedSource.enabled !== false}
|
||||
onChange={(enabled) => updateSource(selectedSource.key, { enabled })}
|
||||
/>
|
||||
<LabeledInput
|
||||
label="账号标识"
|
||||
value={selectedSource.key}
|
||||
onChange={(value) => updateSource(selectedSource.key, { key: value })}
|
||||
/>
|
||||
<LabeledInput
|
||||
label="显示名称"
|
||||
value={selectedSource.label}
|
||||
onChange={(value) => updateSource(selectedSource.key, { label: value })}
|
||||
/>
|
||||
<LabeledInput
|
||||
label="接口地址"
|
||||
value={selectedSource.baseUrl}
|
||||
onChange={(value) => updateSource(selectedSource.key, { baseUrl: value })}
|
||||
/>
|
||||
<LabeledInput
|
||||
label="用户名"
|
||||
value={selectedSource.username}
|
||||
onChange={(value) => updateSource(selectedSource.key, { username: value })}
|
||||
/>
|
||||
<LabeledInput
|
||||
label="密码"
|
||||
password
|
||||
value={selectedSource.password}
|
||||
onChange={(value) => updateSource(selectedSource.key, { password: value })}
|
||||
/>
|
||||
<LabeledInput
|
||||
label="手机号"
|
||||
value={selectedSource.phone}
|
||||
onChange={(value) => updateSource(selectedSource.key, { phone: value })}
|
||||
/>
|
||||
<LabeledInput
|
||||
label="设备 ID"
|
||||
value={selectedSource.deviceId}
|
||||
onChange={(value) => updateSource(selectedSource.key, { deviceId: value })}
|
||||
/>
|
||||
<NumberField
|
||||
label="设备类型"
|
||||
value={selectedSource.deviceType}
|
||||
min={0}
|
||||
onChange={(deviceType) => updateSource(selectedSource.key, { deviceType })}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</Space>
|
||||
<Alert
|
||||
className="platform-section-gap"
|
||||
type="info"
|
||||
showIcon
|
||||
message={`当前调试账号:${formatCloudtentaclesSourceLabel(selectedSource)}(${selectedSource.key})`}
|
||||
description="下方登录 / 查余额 / 查商品都只作用于当前选中账号。"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -1673,8 +2028,12 @@ function CloudtentaclesPlatformPanel({
|
||||
<Alert
|
||||
type={selectedSession?.hasToken ? 'success' : 'info'}
|
||||
showIcon
|
||||
message={selectedSession?.hasToken ? '当前账号已有持久化会话' : '当前账号还没有可用会话'}
|
||||
description={`登录时间:${formatAdminDateTime(selectedSession?.loggedInAt || '')}`}
|
||||
message={
|
||||
selectedSession?.hasToken
|
||||
? `${formatCloudtentaclesSourceLabel(selectedSource)} 已有持久化会话`
|
||||
: `${formatCloudtentaclesSourceLabel(selectedSource)} 还没有可用会话`
|
||||
}
|
||||
description={`sourceKey:${selectedSource.key} · 登录时间:${formatAdminDateTime(selectedSession?.loggedInAt || '')}`}
|
||||
/>
|
||||
<Space wrap className="platform-section-gap">
|
||||
<Input
|
||||
@@ -1757,7 +2116,7 @@ function CloudtentaclesPlatformPanel({
|
||||
})
|
||||
}
|
||||
>
|
||||
查资产
|
||||
查余额
|
||||
</Button>
|
||||
<Button
|
||||
loading={debugLoading === 'sku'}
|
||||
@@ -1785,6 +2144,10 @@ function CloudtentaclesPlatformPanel({
|
||||
)
|
||||
}
|
||||
|
||||
function formatCloudtentaclesSourceLabel(source: Pick<AdminCloudtentaclesSourceItem, 'label' | 'username' | 'key'>) {
|
||||
return String(source.label || source.username || source.key || '').trim() || source.key
|
||||
}
|
||||
|
||||
function resolveIndustryAccessTokenStatus(
|
||||
source: Pick<AdminKuaishouIndustrySourceConfig, 'accessTokenStatus'>,
|
||||
) {
|
||||
|
||||
@@ -9,7 +9,7 @@ export const ADMIN_DEFAULT_PAGE_SIZE = 20
|
||||
*/
|
||||
export const ADMIN_PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const
|
||||
|
||||
/** 云触手发货记录:上游接口更适合大批量拉取 */
|
||||
/** kuaishou-lewan 发货记录:上游接口更适合大批量拉取 */
|
||||
export const CLOUDTENTACLES_DEFAULT_PAGE_SIZE = 100
|
||||
export const CLOUDTENTACLES_PAGE_SIZE_OPTIONS = [100, 200, 500, 1000] as const
|
||||
|
||||
@@ -60,7 +60,7 @@ export function buildAdminLocalTablePagination(
|
||||
}
|
||||
}
|
||||
|
||||
/** 云触手发货记录分页(100–1000) */
|
||||
/** kuaishou-lewan 发货记录分页(100–1000) */
|
||||
export function buildCloudtentaclesTablePagination(options: {
|
||||
current?: number
|
||||
page?: number
|
||||
|
||||
Reference in New Issue
Block a user