merge: release script monitor operations and store account completion

# Conflicts:
#	furniture-3d-system/prisma/schema.prisma
#	furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/bank-account/route.ts
#	furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/import/route.ts
#	furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/route.ts
#	furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/template/route.ts
This commit is contained in:
WuYongJie 2026-08-28 18:28:11 +08:00
commit 85c31677c5
28 changed files with 1242 additions and 122 deletions

View File

@ -8,6 +8,11 @@
2. 复制本目录到目标机器,把 `config.example.json` 复制为 `config.json`,填入凭据、内外网地址和脚本任务说明。
3. 先运行 `node agent.mjs` 确认上报,再使用 `pm2 start ecosystem.config.cjs` 常驻。
需要在平台点击“立即运行”的脚本,必须在 `config.json` 对应任务中显式配置
`"allowManualRun": true`。平台只会下发脚本 `key`,Agent 始终执行本机配置里预存的
`command`,不会接收平台传来的任意命令。Agent 默认每 5 秒主动领取一次执行请求,
因此内网机器不需要开放入站端口。
`connectionMode` 支持:
- `intranet`:只走内网地址。

View File

@ -1,5 +1,5 @@
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { execFileSync, spawn } from 'node:child_process'
import { existsSync, readFileSync, statSync } from 'node:fs'
import { hostname, platform, release } from 'node:os'
import { dirname, resolve } from 'node:path'
@ -26,6 +26,7 @@ export function loadConfig(configPath = process.env.SCRIPT_MONITOR_CONFIG || res
}
config.reportIntervalSec = Math.max(15, Number(config.reportIntervalSec) || 60)
config.requestTimeoutMs = Math.max(1000, Number(config.requestTimeoutMs) || 10000)
config.commandPollIntervalSec = Math.min(60, Math.max(2, Number(config.commandPollIntervalSec) || 5))
config.scripts = Array.isArray(config.scripts) ? config.scripts : []
return config
}
@ -120,8 +121,9 @@ function scanPm2() {
schedule: null,
commandPreview: redactCommand(processInfo.pm2_env?.pm_exec_path || ''),
expectedIntervalSec: null,
status,
isEnabled: status !== 'DISABLED',
status,
isEnabled: status !== 'DISABLED',
canManualRun: false,
lastStartedAt: processInfo.pm2_env?.pm_uptime
? new Date(processInfo.pm2_env.pm_uptime).toISOString()
: null,
@ -152,6 +154,7 @@ function parseCronLine(line) {
expectedIntervalSec: null,
status: 'UNKNOWN',
isEnabled: true,
canManualRun: false,
}
}
@ -229,6 +232,7 @@ function normalizeConfiguredScript(script) {
expectedIntervalSec: script.expectedIntervalSec ? Number(script.expectedIntervalSec) : null,
status: result.status,
isEnabled: script.enabled !== false,
canManualRun: script.allowManualRun === true && Boolean(script.command),
lastResultSummary: result.summary,
}
}
@ -260,6 +264,110 @@ export async function reportSnapshot(config) {
console.log(`[script-monitor] 已通过${result.endpoint.kind === 'INTRANET' ? '内网' : '公网'}上报 ${scripts.length} 个脚本`)
}
function keepTail(current, chunk, limit = 12000) {
const next = current + chunk.toString()
return next.length > limit ? next.slice(-limit) : next
}
async function reportManualRun(config, command, input) {
return postToCenter(config, '/api/internal/script-monitor/run', {
commandId: command.id,
scriptKey: command.scriptKey,
scriptName: command.scriptName,
taskDescription: command.taskDescription || '',
jobType: String(command.jobType || 'OTHER').toUpperCase(),
...input,
})
}
export async function executeManualCommand(config, command) {
const script = config.scripts.find((item) => item.key === command.scriptKey)
const startedAt = new Date()
const externalRunId = `manual:${command.id}`
if (!script?.command || script.allowManualRun !== true) {
const message = 'Agent 配置未开放该脚本的手动运行权限'
await reportManualRun(config, command, {
externalRunId,
status: 'FAILED',
trigger: 'manual',
startedAt: startedAt.toISOString(),
finishedAt: new Date().toISOString(),
durationMs: 0,
exitCode: 1,
resultSummary: message,
errorTail: message,
})
return
}
await reportManualRun(config, command, {
externalRunId,
status: 'RUNNING',
trigger: 'manual',
startedAt: startedAt.toISOString(),
})
let outputTail = ''
let errorTail = ''
try {
const child = spawn(script.command, {
cwd: script.cwd ? resolve(script.cwd) : process.cwd(),
env: process.env,
shell: true,
stdio: ['ignore', 'pipe', 'pipe'],
})
child.stdout.on('data', (chunk) => {
outputTail = keepTail(outputTail, chunk)
process.stdout.write(chunk)
})
child.stderr.on('data', (chunk) => {
errorTail = keepTail(errorTail, chunk)
process.stderr.write(chunk)
})
const exitCode = await new Promise((resolveExit, reject) => {
child.once('error', reject)
child.once('close', (code) => resolveExit(code ?? 1))
})
const finishedAt = new Date()
await reportManualRun(config, command, {
externalRunId,
status: exitCode === 0 ? 'SUCCESS' : 'FAILED',
trigger: 'manual',
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
exitCode,
resultSummary: exitCode === 0 ? '手动运行成功' : `手动运行失败,退出码 ${exitCode}`,
outputTail,
errorTail,
})
} catch (error) {
const finishedAt = new Date()
await reportManualRun(config, command, {
externalRunId,
status: 'FAILED',
trigger: 'manual',
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
exitCode: 1,
resultSummary: `手动运行启动失败:${error.message}`,
errorTail: error.message,
})
}
}
export async function pollManualCommand(config) {
const result = await postToCenter(
config,
'/api/internal/script-monitor/commands/claim',
{},
)
const command = result.body?.data?.command
if (!command) return false
await executeManualCommand(config, command)
return true
}
async function main() {
const config = loadConfig()
const run = async () => {
@ -269,10 +377,25 @@ async function main() {
console.error('[script-monitor] 上报失败:', error.message)
}
}
let commandBusy = false
const poll = async () => {
if (commandBusy) return
commandBusy = true
try {
await pollManualCommand(config)
} catch (error) {
console.error('[script-monitor] 手动任务轮询失败:', error.message)
} finally {
commandBusy = false
}
}
await run()
await poll()
const timer = setInterval(run, config.reportIntervalSec * 1000)
const commandTimer = setInterval(poll, config.commandPollIntervalSec * 1000)
const stop = () => {
clearInterval(timer)
clearInterval(commandTimer)
process.exit(0)
}
process.on('SIGINT', stop)

View File

@ -0,0 +1,79 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { executeManualCommand } from './agent.mjs'
function config(overrides = {}) {
return {
agentId: 'agent-1',
agentToken: 'secret-token',
connectionMode: 'internet',
centerUrls: { internet: 'https://monitor.example.test' },
requestTimeoutMs: 5000,
scripts: [{
key: 'local-check',
name: '本机检查',
taskDescription: '验证手动触发',
jobType: 'MANUAL',
allowManualRun: true,
command: `${JSON.stringify(process.execPath)} -e "console.log('manual-ok')"`,
}],
...overrides,
}
}
function command() {
return {
id: 'command-1',
scriptKey: 'local-check',
scriptName: '本机检查',
taskDescription: '验证手动触发',
jobType: 'MANUAL',
}
}
test('manual command executes only the local configured command and reports its result', async () => {
const previousFetch = globalThis.fetch
const reports = []
globalThis.fetch = async (_url, init) => {
reports.push(JSON.parse(init.body))
return new Response(JSON.stringify({ success: true, data: { ok: true } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
try {
await executeManualCommand(config(), command())
} finally {
globalThis.fetch = previousFetch
}
assert.equal(reports.length, 2)
assert.equal(reports[0].status, 'RUNNING')
assert.equal(reports[1].status, 'SUCCESS')
assert.equal(reports[1].exitCode, 0)
assert.match(reports[1].outputTail, /manual-ok/)
assert.equal(reports[1].commandId, 'command-1')
})
test('manual command is rejected when allowManualRun is not explicitly enabled', async () => {
const previousFetch = globalThis.fetch
const reports = []
globalThis.fetch = async (_url, init) => {
reports.push(JSON.parse(init.body))
return new Response(JSON.stringify({ success: true, data: { ok: true } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
const blockedConfig = config()
blockedConfig.scripts[0].allowManualRun = false
try {
await executeManualCommand(blockedConfig, command())
} finally {
globalThis.fetch = previousFetch
}
assert.equal(reports.length, 1)
assert.equal(reports[0].status, 'FAILED')
assert.match(reports[0].resultSummary, /未开放/)
})

View File

@ -8,6 +8,7 @@
"internet": "https://cp.jiansun.vip"
},
"reportIntervalSec": 60,
"commandPollIntervalSec": 5,
"requestTimeoutMs": 10000,
"autoDiscover": {
"cron": true,
@ -21,6 +22,7 @@
"jobType": "CRON",
"schedule": "*/15 * * * *",
"expectedIntervalSec": 900,
"allowManualRun": true,
"command": "npm run sync:supply-chain-product-archive",
"cwd": "/opt/furniture-3d-system-prod/current",
"resultCheck": {

View File

@ -0,0 +1,32 @@
-- CreateEnum
CREATE TYPE "ScriptMonitorCommandStatus" AS ENUM ('PENDING', 'RUNNING', 'SUCCESS', 'FAILED', 'EXPIRED');
-- AlterTable
ALTER TABLE "ScriptMonitorScript"
ADD COLUMN "canManualRun" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "ScriptMonitorCommand" (
"id" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"scriptId" TEXT NOT NULL,
"status" "ScriptMonitorCommandStatus" NOT NULL DEFAULT 'PENDING',
"requestedById" TEXT,
"requestedByName" TEXT,
"requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"claimedAt" TIMESTAMP(3),
"finishedAt" TIMESTAMP(3),
"externalRunId" TEXT,
"errorMessage" TEXT,
CONSTRAINT "ScriptMonitorCommand_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ScriptMonitorCommand_agentId_status_requestedAt_idx" ON "ScriptMonitorCommand"("agentId", "status", "requestedAt");
CREATE INDEX "ScriptMonitorCommand_scriptId_requestedAt_idx" ON "ScriptMonitorCommand"("scriptId", "requestedAt" DESC);
-- AddForeignKey
ALTER TABLE "ScriptMonitorCommand" ADD CONSTRAINT "ScriptMonitorCommand_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "ScriptMonitorAgent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ScriptMonitorCommand" ADD CONSTRAINT "ScriptMonitorCommand_scriptId_fkey" FOREIGN KEY ("scriptId") REFERENCES "ScriptMonitorScript"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -3512,6 +3512,7 @@ model StoreAccount {
@@index([accountType])
@@index([updatedAt])
}
model StoreAccountStoreProfile {
id String @id @default(cuid())
storeName String
@ -3529,6 +3530,7 @@ model StoreAccountStoreProfile {
@@index([storeName])
@@index([platform])
}
model StoreAccountChangeLog {
id String @id @default(cuid())
storeAccountId String?
@ -3987,6 +3989,14 @@ enum ScriptMonitorRunStatus {
FAILED
}
enum ScriptMonitorCommandStatus {
PENDING
RUNNING
SUCCESS
FAILED
EXPIRED
}
model ScriptMonitorAgent {
id String @id @default(cuid())
name String
@ -4004,6 +4014,7 @@ model ScriptMonitorAgent {
updatedAt DateTime @updatedAt
scripts ScriptMonitorScript[]
runs ScriptMonitorRun[]
commands ScriptMonitorCommand[]
@@index([isActive, lastSeenAt])
}
@ -4021,6 +4032,7 @@ model ScriptMonitorScript {
expectedIntervalSec Int?
status ScriptMonitorJobStatus @default(UNKNOWN)
isEnabled Boolean @default(true)
canManualRun Boolean @default(false)
lastStartedAt DateTime?
lastFinishedAt DateTime?
lastDurationMs Int?
@ -4032,6 +4044,7 @@ model ScriptMonitorScript {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
runs ScriptMonitorRun[]
commands ScriptMonitorCommand[]
@@unique([agentId, externalKey])
@@index([agentId, status])
@ -4060,3 +4073,23 @@ model ScriptMonitorRun {
@@index([scriptId, startedAt(sort: Desc)])
@@index([agentId, startedAt(sort: Desc)])
}
model ScriptMonitorCommand {
id String @id @default(cuid())
agentId String
agent ScriptMonitorAgent @relation(fields: [agentId], references: [id], onDelete: Cascade)
scriptId String
script ScriptMonitorScript @relation(fields: [scriptId], references: [id], onDelete: Cascade)
status ScriptMonitorCommandStatus @default(PENDING)
requestedById String?
requestedByName String?
requestedAt DateTime @default(now())
expiresAt DateTime
claimedAt DateTime?
finishedAt DateTime?
externalRunId String?
errorMessage String? @db.Text
@@index([agentId, status, requestedAt])
@@index([scriptId, requestedAt(sort: Desc)])
}

View File

@ -0,0 +1,25 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { scriptMonitorService } from '@/server/services/script-monitor.service'
function readAgentCredentials(request: NextRequest) {
const agentId = request.headers.get('x-script-monitor-agent-id')?.trim() ?? ''
const authorization = request.headers.get('authorization') ?? ''
const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : ''
return { agentId, token }
}
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const { agentId, token } = readAgentCredentials(request)
return jsonSuccess(
await scriptMonitorService.claimCommand(agentId, token),
200,
ctx.requestId,
)
},
{ requireAuth: false },
)
}

View File

@ -0,0 +1,36 @@
import { NextRequest } from 'next/server'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { scriptMonitorService } from '@/server/services/script-monitor.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
export async function POST(
request: NextRequest,
context: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(
ctx.user,
'scriptMonitor',
)
if (!access.edit) {
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
'没有手动运行脚本的权限',
)
}
const { id } = await context.params
return jsonSuccess(
await scriptMonitorService.requestManualRun(id, {
id: ctx.user.id,
name: ctx.user.name,
}),
202,
ctx.requestId,
)
},
{ requireAuth: true },
)
}

View File

@ -7,6 +7,8 @@ type RouteProps = { params: Promise<{ id: string }> }
export async function GET(request: NextRequest, props: RouteProps) {
return handleRequest(request, async (ctx) => {
const { id } = await props.params
return jsonSuccess(await storeAccountManagementService.revealBankAccount(id, ctx.user), 200, ctx.requestId)
const response = jsonSuccess(await storeAccountManagementService.revealBankAccount(id, ctx.user), 200, ctx.requestId)
response.headers.set('Cache-Control', 'no-store')
return response
}, { requireAuth: true })
}

View File

@ -10,9 +10,11 @@ const updateSchema = z.object({
operationDepartmentHead: z.string().trim().max(240).optional(),
operationDirector: z.string().trim().max(240).optional(),
profitCenter: z.string().trim().max(240).optional(),
subject: z.string().trim().max(240).optional(),
accountType: z.string().trim().max(20).optional(),
account: z.string().trim().max(240).optional(),
password: z.string().max(500).optional(),
boundBankAccountNumber: z.string().trim().max(500).optional(),
boundPhone: z.string().trim().max(80).optional(),
verificationContact: z.string().trim().max(240).optional(),
jstOrderService: z.string().trim().max(240).optional(),

View File

@ -6,8 +6,8 @@ import { handleRequest, jsonError } from '@/server/core/response'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
const HEADER_MAP: Record<string, string> = {
店铺名称: 'storeName', 店铺运营负责人: 'operationOwner', 店铺运营部门负责人: 'operationDepartmentHead', 店铺运营总监: 'operationDirector', 店铺所属利润中心: 'profitCenter', 账号类型: 'accountType', 账号: 'account', 密码: 'password', 绑定手机号: 'boundPhone', 手机号验证码对接人: 'verificationContact', 聚水潭订单服务号: 'jstOrderService', 账号分组名称: 'accountGroupName',
storeName: 'storeName', operationOwner: 'operationOwner', operationDepartmentHead: 'operationDepartmentHead', operationDirector: 'operationDirector', profitCenter: 'profitCenter', accountType: 'accountType', account: 'account', password: 'password', boundPhone: 'boundPhone', verificationContact: 'verificationContact', jstOrderService: 'jstOrderService', accountGroupName: 'accountGroupName',
店铺名称: 'storeName', 店铺运营负责人: 'operationOwner', 店铺运营部门负责人: 'operationDepartmentHead', 店铺运营总监: 'operationDirector', 店铺所属利润中心: 'profitCenter', 主体: 'subject', 账号类型: 'accountType', 账号: 'account', 密码: 'password', 店铺绑定银行卡号: 'boundBankAccountNumber', 绑定手机号: 'boundPhone', 手机号验证码对接人: 'verificationContact', 聚水潭订单服务号: 'jstOrderService', 账号分组名称: 'accountGroupName',
storeName: 'storeName', operationOwner: 'operationOwner', operationDepartmentHead: 'operationDepartmentHead', operationDirector: 'operationDirector', profitCenter: 'profitCenter', subject: 'subject', accountType: 'accountType', account: 'account', password: 'password', boundBankAccountNumber: 'boundBankAccountNumber', boundPhone: 'boundPhone', verificationContact: 'verificationContact', jstOrderService: 'jstOrderService', accountGroupName: 'accountGroupName',
}
export async function POST(request: NextRequest) {

View File

@ -1,39 +1,58 @@
import { NextRequest } from 'next/server'
import * as XLSX from 'xlsx'
import {
STORE_ACCOUNT_PROFILE_FIELDS,
STORE_ACCOUNT_PROFILE_PLATFORMS,
type StoreAccountProfilePlatform,
} from '@/lib/store-account-management'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
import { STORE_ACCOUNT_PROFILE_FIELDS } from '@/lib/store-account-management'
const HEADER_MAP: Record<string, string> = Object.fromEntries([
const HEADER_MAP = new Map<string, string>([
['店铺名称', 'storeName'],
...STORE_ACCOUNT_PROFILE_FIELDS.map((field) => [field.label, field.key]),
['storeName', 'storeName'],
...STORE_ACCOUNT_PROFILE_FIELDS.flatMap((field) => [
[field.label, field.key],
[field.key, field.key],
] as Array<[string, string]>),
])
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const form = await request.formData()
const file = form.get('file')
const platform = String(form.get('platform') ?? '')
if (!(file instanceof File)) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId)
if (file.size > 10 * 1024 * 1024) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件不能超过10MB', 400, ctx.requestId)
const platform = String(form.get('platform') ?? '').trim().toUpperCase()
if (!(file instanceof File)) {
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId)
}
if (!STORE_ACCOUNT_PROFILE_PLATFORMS.some((item) => item.key === platform)) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '平台只能是京东或京喜', 400, ctx.requestId)
}
if (file.size > 10 * 1024 * 1024) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件不能超过10MB', 400, ctx.requestId)
}
const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer', cellDates: false })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
if (!sheet) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId)
const rawRows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' })
if (rawRows.length === 0) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId)
if (rawRows.length > 5000) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '单次最多导入5000行', 400, ctx.requestId)
let processed = 0
for (const raw of rawRows) {
const mapped = Object.fromEntries(Object.entries(raw).flatMap(([header, value]) => {
const key = HEADER_MAP[header.trim()]
return key ? [[key, value]] : []
}))
const storeName = String(mapped.storeName ?? '').trim()
if (!storeName) throw new Error(`第${processed + 2}行缺少店铺名称`)
await storeAccountManagementService.saveStoreProfile({ storeName, platform, values: mapped, user: ctx.user })
processed += 1
if (!sheet) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId)
}
return jsonSuccess({ processed, total: rawRows.length }, 200, ctx.requestId)
const rawRows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' })
const rows = rawRows.map((raw) => Object.fromEntries(Object.entries(raw).flatMap(([header, value]) => {
const key = HEADER_MAP.get(header.trim())
return key ? [[key, value]] : []
})))
if (rows.length === 0) {
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId)
}
if (rows.length > 5000) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '单次最多导入5000行', 400, ctx.requestId)
}
const result = await storeAccountManagementService.importStoreProfiles({
platform: platform as StoreAccountProfilePlatform,
rows,
user: ctx.user,
})
return jsonSuccess(result, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -4,10 +4,15 @@ import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
const schema = z.object({
const profileSchema = z.object({
storeName: z.string().trim().min(1).max(240),
platform: z.string().trim().min(1).max(10),
values: z.record(z.string(), z.unknown()).default({}),
platform: z.enum(['JD', 'JX']),
values: z.object({
merchantNumber: z.string().trim().max(500).optional(),
secondaryMerchantNumber: z.string().trim().max(500).optional(),
merchantStoreId: z.string().trim().max(500).optional(),
companyId: z.string().trim().max(500).optional(),
}).strict(),
}).strict()
export async function GET(request: NextRequest) {
@ -19,8 +24,14 @@ export async function GET(request: NextRequest) {
export async function PATCH(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const parsed = schema.safeParse(await request.json().catch(() => null))
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '店铺资料内容不正确', 400, ctx.requestId)
return jsonSuccess(await storeAccountManagementService.saveStoreProfile({ ...parsed.data, user: ctx.user }), 200, ctx.requestId)
const body = await request.json().catch(() => null)
const parsed = profileSchema.safeParse(body)
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '店铺资料内容不正确', 400, ctx.requestId)
}
return jsonSuccess(await storeAccountManagementService.saveStoreProfile({
...parsed.data,
user: ctx.user,
}), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -1,30 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import ExcelJS from 'exceljs'
import { STORE_ACCOUNT_PROFILE_FIELDS, STORE_ACCOUNT_PROFILE_PLATFORMS, type StoreAccountProfilePlatform } from '@/lib/store-account-management'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError } from '@/server/core/response'
import { storeAccountManagementAccessService } from '@/server/services/store-account-management-access.service'
import * as XLSX from 'xlsx'
import {
STORE_ACCOUNT_PROFILE_FIELDS,
STORE_ACCOUNT_PROFILE_PLATFORMS,
type StoreAccountProfilePlatform,
} from '@/lib/store-account-management'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest } from '@/server/core/response'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const capabilities = await storeAccountManagementAccessService.getCapabilities(ctx.user)
if (!capabilities.pageAccess.edit) return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有编辑店铺资料的权限', 403, ctx.requestId)
const platform = request.nextUrl.searchParams.get('platform')?.toUpperCase() as StoreAccountProfilePlatform
const platformLabel = STORE_ACCOUNT_PROFILE_PLATFORMS.find((item) => item.key === platform)?.label
if (!platformLabel) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '平台只能是京东或京喜', 400, ctx.requestId)
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet(`${platformLabel}店铺资料`)
sheet.columns = [{ header: '店铺名称', key: 'storeName', width: 28 }, ...STORE_ACCOUNT_PROFILE_FIELDS.map((field) => ({ header: field.label, key: field.key, width: 28 }))]
sheet.views = [{ state: 'frozen', ySplit: 1 }]
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } }
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF27272A' } }
const content = Buffer.from(await workbook.xlsx.writeBuffer())
const platform = request.nextUrl.searchParams.get('platform')?.trim().toUpperCase()
if (!STORE_ACCOUNT_PROFILE_PLATFORMS.some((item) => item.key === platform)) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '平台只能是京东或京喜')
}
const { target } = await storeAccountManagementService.getFieldAccess(ctx.user)
if (!target.view) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有店铺账号管理权限')
}
const normalizedPlatform = platform as StoreAccountProfilePlatform
const label = normalizedPlatform === 'JD' ? '京东' : '京喜'
const headers = ['店铺名称', ...STORE_ACCOUNT_PROFILE_FIELDS.map((field) => field.label)]
const sheet = XLSX.utils.aoa_to_sheet([headers])
sheet['!cols'] = headers.map((header) => ({ wch: Math.max(18, header.length * 2 + 4) }))
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, sheet, `${label}店铺资料`)
const content = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer
return new NextResponse(new Uint8Array(content), {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(`${platformLabel}店铺资料导入模板.xlsx`)}`,
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(`${label}店铺资料导入模板.xlsx`)}`,
'Cache-Control': 'private, no-store, max-age=0',
'X-Content-Type-Options': 'nosniff',
},
})
}, { requireAuth: true })

View File

@ -11,9 +11,11 @@ const createSchema = z.object({
operationDepartmentHead: z.string().trim().max(240).optional(),
operationDirector: z.string().trim().max(240).optional(),
profitCenter: z.string().trim().max(240).optional(),
subject: z.string().trim().max(240).optional(),
accountType: z.string().trim().min(1).max(20),
account: z.string().trim().min(1).max(240),
password: z.string().max(500).optional(),
boundBankAccountNumber: z.string().trim().max(500).optional(),
boundPhone: z.string().trim().max(80).optional(),
verificationContact: z.string().trim().max(240).optional(),
jstOrderService: z.string().trim().max(240).optional(),

View File

@ -10,6 +10,7 @@ const overview: ScriptMonitorOverview = {
hostname: 'warehouse-01',
networkMode: 'AUTO',
connectionKind: 'INTRANET',
connectionState: 'ONLINE',
online: true,
isActive: true,
os: 'linux 6.8',
@ -30,6 +31,7 @@ const overview: ScriptMonitorOverview = {
expectedIntervalSec: 900,
status: 'SUCCESS',
isEnabled: true,
canManualRun: true,
lastStartedAt: '2026-08-28T11:45:00.000Z',
lastFinishedAt: '2026-08-28T11:45:12.000Z',
lastDurationMs: 12_000,
@ -44,14 +46,18 @@ const overview: ScriptMonitorOverview = {
hostname: 'warehouse-01',
networkMode: 'AUTO',
connectionKind: 'INTRANET',
connectionState: 'ONLINE',
online: true,
lastSeenAt: '2026-08-28T11:59:30.000Z',
},
latestRun: null,
latestCommand: null,
}],
metrics: {
agentTotal: 1,
agentConnected: 1,
agentOnline: 1,
agentPending: 0,
scriptTotal: 1,
healthy: 1,
running: 0,
@ -67,9 +73,11 @@ describe('ScriptMonitorView', () => {
expect(screen.getByRole('heading', { name: '脚本监控' })).toBeInTheDocument()
expect(screen.getByText('供应链产品档案同步')).toBeInTheDocument()
expect(screen.getByText('同步商品、仓库和供应商扩展字段')).toBeInTheDocument()
expect(screen.getAllByText('仓库服务器 01')).toHaveLength(2)
expect(screen.getByText('仓库服务器 01')).toBeInTheDocument()
expect(screen.getByText('同步 182 条档案')).toBeInTheDocument()
expect(screen.getByText('内网')).toBeInTheDocument()
expect(screen.getByText('在线 · 内网')).toBeInTheDocument()
expect(screen.getAllByText('最近成功')).toHaveLength(2)
expect(screen.getByRole('button', { name: '立即运行' })).toBeEnabled()
})
it('keeps the empty state actionable for editors', () => {
@ -78,7 +86,7 @@ describe('ScriptMonitorView', () => {
...overview,
agents: [],
scripts: [],
metrics: { agentTotal: 0, agentOnline: 0, scriptTotal: 0, healthy: 0, running: 0, abnormal: 0, unknown: 0 },
metrics: { agentTotal: 0, agentConnected: 0, agentOnline: 0, agentPending: 0, scriptTotal: 0, healthy: 0, running: 0, abnormal: 0, unknown: 0 },
}}
canEdit
databaseReady
@ -87,4 +95,41 @@ describe('ScriptMonitorView', () => {
expect(screen.getByText('还没有接入机器')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /接入第一台机器/ })).toBeInTheDocument()
})
it('shows a never-connected credential as pending without counting it as connected', () => {
render(<ScriptMonitorView
initialOverview={{
...overview,
agents: [{
...overview.agents[0],
id: 'agent-pending',
name: '测试机器',
hostname: null,
connectionKind: null,
connectionState: 'PENDING',
online: false,
lastSeenAt: null,
scriptCount: 0,
}],
scripts: [],
metrics: {
agentTotal: 1,
agentConnected: 0,
agentOnline: 0,
agentPending: 1,
scriptTotal: 0,
healthy: 0,
running: 0,
abnormal: 0,
unknown: 0,
},
}}
canEdit
databaseReady
/>)
expect(screen.getAllByText('待接入')).toHaveLength(2)
expect(screen.getByText(/不计入已接入机器/)).toBeInTheDocument()
expect(screen.getByText('共 0 台已接入')).toBeInTheDocument()
})
})

View File

@ -3,11 +3,12 @@
import {
Activity,
Check,
CircleCheck,
Clock3,
Copy,
Globe2,
LoaderCircle,
Network,
Play,
Plus,
RefreshCw,
Search,
@ -35,12 +36,12 @@ type ApiEnvelope<T> = {
}
const STATUS_META: Record<ScriptMonitorJobStatus, { label: string; className: string }> = {
RUNNING: { label: '运行中', className: 'monitor-status-running' },
SUCCESS: { label: '正常', className: 'monitor-status-success' },
FAILED: { label: '失败', className: 'monitor-status-failed' },
RUNNING: { label: '正在运行', className: 'monitor-status-running' },
SUCCESS: { label: '最近成功', className: 'monitor-status-success' },
FAILED: { label: '最近失败', className: 'monitor-status-failed' },
SILENT: { label: '超时未运行', className: 'monitor-status-silent' },
DISABLED: { label: '已停用', className: 'monitor-status-disabled' },
UNKNOWN: { label: '待确认', className: 'monitor-status-unknown' },
UNKNOWN: { label: '尚无结果', className: 'monitor-status-unknown' },
}
const JOB_TYPE_LABELS = {
@ -103,23 +104,55 @@ function StatusBadge({ status }: { status: ScriptMonitorJobStatus }) {
)
}
function ConnectionBadge({ script }: { script: ScriptMonitorScriptRow }) {
if (!script.agent.online) {
function AgentConnectionBadge({
agent,
}: {
agent: ScriptMonitorOverview['agents'][number] | ScriptMonitorScriptRow['agent']
}) {
if (agent.connectionState === 'PENDING') {
return (
<span className="monitor-connection monitor-connection-offline">
<WifiOff size={12} aria-hidden="true" />离线
<span className="monitor-connection monitor-connection-pending">
<Clock3 size={12} aria-hidden="true" />待接入
</span>
)
}
const intranet = script.agent.connectionKind === 'INTRANET'
if (!agent.online) {
return (
<span className="monitor-connection monitor-connection-offline">
<WifiOff size={12} aria-hidden="true" />Agent 离线
</span>
)
}
const intranet = agent.connectionKind === 'INTRANET'
return (
<span className="monitor-connection">
{intranet ? <Network size={12} aria-hidden="true" /> : <Globe2 size={12} aria-hidden="true" />}
{intranet ? '内网' : '公网'}
{intranet ? '在线 · 内网' : '在线 · 公网'}
</span>
)
}
function manualRunState(script: ScriptMonitorScriptRow, canEdit: boolean) {
if (!canEdit) return { disabled: true, label: '立即运行', reason: '没有手动运行权限' }
if (script.latestCommand?.status === 'PENDING') {
return { disabled: true, label: '等待 Agent', reason: '执行请求正在等待 Agent 领取' }
}
if (script.latestCommand?.status === 'RUNNING' || script.status === 'RUNNING') {
return { disabled: true, label: '执行中', reason: '脚本正在运行' }
}
if (!script.agent.online) {
return { disabled: true, label: '立即运行', reason: 'Agent 离线,无法手动运行' }
}
if (!script.canManualRun) {
return {
disabled: true,
label: '立即运行',
reason: 'Agent 配置未设置 allowManualRun: true',
}
}
return { disabled: false, label: '立即运行', reason: '运行 Agent 本机预存命令' }
}
export function ScriptMonitorView({
initialOverview,
canEdit,
@ -138,7 +171,10 @@ export function ScriptMonitorView({
const [runsLoading, setRunsLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [connectOpen, setConnectOpen] = useState(false)
const [confirmRun, setConfirmRun] = useState<ScriptMonitorScriptRow | null>(null)
const [runRequesting, setRunRequesting] = useState(false)
const refresh = useCallback(async (quiet = false) => {
if (!databaseReady) return
@ -158,9 +194,13 @@ export function ScriptMonitorView({
useEffect(() => {
if (!databaseReady) return
const timer = window.setInterval(() => refresh(true), 30_000)
const hasActiveCommand = overview.scripts.some((script) =>
script.latestCommand?.status === 'PENDING'
|| script.latestCommand?.status === 'RUNNING'
|| script.status === 'RUNNING')
const timer = window.setInterval(() => refresh(true), hasActiveCommand ? 5_000 : 30_000)
return () => window.clearInterval(timer)
}, [databaseReady, refresh])
}, [databaseReady, overview.scripts, refresh])
const selectedScript = useMemo(
() => overview.scripts.find((script) => script.id === selectedId) ?? null,
@ -192,22 +232,47 @@ export function ScriptMonitorView({
}
}, [selectedScript])
const filteredScripts = useMemo(() => {
const filteredAgentGroups = useMemo(() => {
const keyword = query.trim().toLocaleLowerCase('zh-CN')
return overview.scripts.filter((script) => {
if (statusFilter !== 'ALL' && script.status !== statusFilter) return false
if (agentFilter !== 'ALL' && script.agent.id !== agentFilter) return false
if (!keyword) return true
return [
script.name,
script.taskDescription,
script.agent.name,
script.agent.hostname,
script.schedule,
script.commandPreview,
].some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword))
return overview.agents.flatMap((agent) => {
if (agentFilter !== 'ALL' && agent.id !== agentFilter) return []
const agentMatches = !keyword || [agent.name, agent.hostname, agent.os]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword))
const scripts = overview.scripts.filter((script) => {
if (script.agent.id !== agent.id) return false
if (statusFilter !== 'ALL' && script.status !== statusFilter) return false
if (agentMatches) return true
return [script.name, script.taskDescription, script.schedule, script.commandPreview]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword))
})
const showEmptyAgent = statusFilter === 'ALL' && agentMatches
return scripts.length > 0 || showEmptyAgent ? [{ agent, scripts }] : []
})
}, [agentFilter, overview.scripts, query, statusFilter])
}, [agentFilter, overview.agents, overview.scripts, query, statusFilter])
const filteredScripts = useMemo(
() => filteredAgentGroups.flatMap((group) => group.scripts),
[filteredAgentGroups],
)
async function requestManualRun() {
if (!confirmRun) return
setRunRequesting(true)
setError(null)
try {
await readApi(await fetch(`/api/supply-chain/script-monitor/scripts/${confirmRun.id}/run`, {
method: 'POST',
}))
setNotice(`已发送“${confirmRun.name}”执行请求,等待 Agent 领取`)
setSelectedId(confirmRun.id)
setConfirmRun(null)
await refresh(true)
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '手动运行请求发送失败')
} finally {
setRunRequesting(false)
}
}
return (
<div className="monitor-page">
@ -240,9 +305,9 @@ export function ScriptMonitorView({
</header>
<section className="monitor-metrics" aria-label="脚本监控摘要">
<div><Server size={16} /><span>机器</span><strong>{overview.metrics.agentOnline}/{overview.metrics.agentTotal}</strong><small>在线</small></div>
<div><Server size={16} /><span>在线机器</span><strong>{overview.metrics.agentOnline}</strong><small>共 {overview.metrics.agentConnected} 台已接入</small></div>
<div className={overview.metrics.agentPending > 0 ? 'is-pending' : ''}><Clock3 size={16} /><span>待接入</span><strong>{overview.metrics.agentPending}</strong><small>仅生成凭据,尚无心跳</small></div>
<div><Activity size={16} /><span>脚本</span><strong>{overview.metrics.scriptTotal}</strong><small>已汇总</small></div>
<div><CircleCheck size={16} /><span>正常</span><strong>{overview.metrics.healthy + overview.metrics.running}</strong><small>成功或运行中</small></div>
<div className={overview.metrics.abnormal > 0 ? 'is-alert' : ''}><TriangleAlert size={16} /><span>异常</span><strong>{overview.metrics.abnormal}</strong><small>失败或超时</small></div>
</section>
@ -259,6 +324,13 @@ export function ScriptMonitorView({
<button type="button" onClick={() => setError(null)} aria-label="关闭提示"><X size={15} /></button>
</div>
) : null}
{notice ? (
<div className="monitor-notice" role="status">
<Check size={16} aria-hidden="true" />
<span>{notice}</span>
<button type="button" onClick={() => setNotice(null)} aria-label="关闭提示"><X size={15} /></button>
</div>
) : null}
<div className="monitor-toolbar">
<label className="monitor-search">
@ -272,9 +344,13 @@ export function ScriptMonitorView({
</select>
<select value={agentFilter} onChange={(event) => setAgentFilter(event.target.value)} aria-label="按机器筛选">
<option value="ALL">全部机器</option>
{overview.agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
{overview.agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}{agent.connectionState === 'PENDING' ? '(待接入)' : agent.online ? '(在线)' : '(离线)'}
</option>
))}
</select>
<span className="monitor-result-count">{filteredScripts.length} 条</span>
<span className="monitor-result-count">{filteredAgentGroups.length} 台 · {filteredScripts.length} 个脚本</span>
</div>
<main className={`monitor-workspace ${selectedScript ? 'has-inspector' : ''}`}>
@ -286,40 +362,84 @@ export function ScriptMonitorView({
<p>为运行脚本的电脑生成独立 Agent 凭据,首次上报后会自动出现脚本清单。</p>
{canEdit && databaseReady ? <button type="button" className="monitor-button monitor-button-primary" onClick={() => setConnectOpen(true)}><Plus size={15} />接入第一台机器</button> : null}
</section>
) : filteredScripts.length === 0 ? (
) : filteredAgentGroups.length === 0 ? (
<section className="monitor-empty">
<SquareTerminal size={32} strokeWidth={1.4} aria-hidden="true" />
<h2>{overview.scripts.length === 0 ? '等待 Agent 上报脚本' : '没有符合条件的脚本'}</h2>
<p>{overview.scripts.length === 0 ? 'Agent 在线后会定期扫描并上报 Cron、PM2 及配置任务。' : '调整搜索词、状态或机器筛选后重试。'}</p>
<h2>没有符合条件的机器或脚本</h2>
<p>调整搜索词、脚本状态或机器筛选后重试。</p>
</section>
) : (
<table className="monitor-table">
<thead><tr><th>状态</th><th>脚本与任务</th><th>运行机器</th><th className="monitor-col-secondary">类型 / 计划</th><th className="monitor-col-secondary">最近运行</th><th>脚本结果</th></tr></thead>
<tbody>
{filteredScripts.map((script) => (
<tr
key={script.id}
className={selectedId === script.id ? 'is-selected' : ''}
tabIndex={0}
role="button"
aria-label={`查看 ${script.name} 详情`}
onClick={() => setSelectedId(script.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setSelectedId(script.id)
}
}}
>
<td><StatusBadge status={script.status} /></td>
<td><strong>{script.name}</strong><span>{script.taskDescription || '暂未填写任务说明'}</span></td>
<td><strong>{script.agent.name}</strong><span className="monitor-agent-line"><ConnectionBadge script={script} />{script.agent.hostname || '未上报主机名'}</span></td>
<td className="monitor-col-secondary"><strong>{JOB_TYPE_LABELS[script.jobType]}</strong><span>{script.schedule || '持续运行 / 无计划'}</span></td>
<td className="monitor-col-secondary"><strong>{formatDateTime(script.lastFinishedAt ?? script.lastStartedAt)}</strong><span>{formatDuration(script.lastDurationMs)}</span></td>
<td><strong className="monitor-result-summary">{summarizeResult(script)}</strong><span>{script.lastExitCode == null ? '无退出码' : `退出码 ${script.lastExitCode}`}</span></td>
<thead><tr><th>最近状态</th><th>脚本与任务</th><th className="monitor-col-secondary">类型 / 计划</th><th className="monitor-col-secondary">最近运行</th><th>最近结果</th><th>操作</th></tr></thead>
{filteredAgentGroups.map(({ agent, scripts }) => (
<tbody key={agent.id} className="monitor-agent-group">
<tr className="monitor-agent-row">
<td colSpan={6}>
<div className="monitor-agent-summary">
<div className="monitor-agent-identity">
<AgentConnectionBadge agent={agent} />
<strong>{agent.name}</strong>
<span>{agent.hostname || (agent.connectionState === 'PENDING' ? '等待首次上报主机信息' : '未上报主机名')}</span>
</div>
<div className="monitor-agent-meta">
<span>{agent.scriptCount} 个脚本</span>
<span>{agent.lastSeenAt ? `最后心跳 ${formatDateTime(agent.lastSeenAt)}` : '凭据已生成,尚未连接'}</span>
</div>
</div>
</td>
</tr>
))}
</tbody>
{scripts.length === 0 ? (
<tr className="monitor-agent-empty-row">
<td colSpan={6}>
{agent.connectionState === 'PENDING'
? '这台机器尚未成功接入,不计入已接入机器;Agent 首次上报后会显示脚本。'
: '这台机器尚未上报脚本,请检查 Agent 的扫描配置。'}
</td>
</tr>
) : scripts.map((script) => {
const runState = manualRunState(script, canEdit)
return (
<tr
key={script.id}
className={selectedId === script.id ? 'is-selected' : ''}
tabIndex={0}
role="button"
aria-label={`查看 ${script.name} 详情`}
onClick={() => setSelectedId(script.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setSelectedId(script.id)
}
}}
>
<td><StatusBadge status={script.status} /></td>
<td><strong>{script.name}</strong><span>{script.taskDescription || '暂未填写任务说明'}</span></td>
<td className="monitor-col-secondary"><strong>{JOB_TYPE_LABELS[script.jobType]}</strong><span>{script.schedule || '持续运行 / 无计划'}</span></td>
<td className="monitor-col-secondary"><strong>{formatDateTime(script.lastFinishedAt ?? script.lastStartedAt)}</strong><span>{formatDuration(script.lastDurationMs)}</span></td>
<td><strong className="monitor-result-summary">{summarizeResult(script)}</strong><span>{script.lastExitCode == null ? '无退出码' : `退出码 ${script.lastExitCode}`}</span></td>
<td>
<button
type="button"
className="monitor-run-button"
disabled={runState.disabled}
title={runState.reason}
onClick={(event) => {
event.stopPropagation()
setConfirmRun(script)
}}
>
{runState.disabled && runState.label !== '立即运行'
? <LoaderCircle size={13} className={runState.label === '执行中' ? 'animate-spin' : ''} />
: <Play size={13} />}
{runState.label}
</button>
</td>
</tr>
)
})}
</tbody>
))}
</table>
)}
</div>
@ -329,6 +449,8 @@ export function ScriptMonitorView({
script={selectedScript}
runs={runs}
loading={runsLoading}
canEdit={canEdit}
onRequestRun={() => setConfirmRun(selectedScript)}
onClose={() => setSelectedId(null)}
/>
) : null}
@ -340,6 +462,14 @@ export function ScriptMonitorView({
onCreated={() => refresh(true)}
/>
) : null}
{confirmRun ? (
<ConfirmRunDialog
script={confirmRun}
submitting={runRequesting}
onCancel={() => setConfirmRun(null)}
onConfirm={requestManualRun}
/>
) : null}
<style jsx>{`
.monitor-page { display:flex; min-height:0; flex:1; flex-direction:column; color:var(--sc-ink); background:var(--sc-bg); }
@ -361,10 +491,13 @@ export function ScriptMonitorView({
.monitor-metrics strong { grid-row:1 / span 2; grid-column:3; font-variant-numeric:tabular-nums; font-size:24px; line-height:1; letter-spacing:-.03em; }
.monitor-metrics small { grid-column:2; font-size:10px; color:var(--sc-subtle); }
.monitor-metrics .is-alert strong, .monitor-metrics .is-alert svg { color:#9f3a38; }
.monitor-metrics .is-pending strong, .monitor-metrics .is-pending svg { color:#7b5b24; }
.monitor-alert { display:flex; align-items:flex-start; gap:10px; margin:12px 24px 0; border:1px solid #d9c4ba; border-radius:9px; padding:10px 12px; background:#fffaf7; color:#69443a; font-size:12px; }
.monitor-alert div { display:flex; flex:1; flex-direction:column; gap:2px; }
.monitor-alert span { color:#7b625a; }
.monitor-alert button { padding:3px; }
.monitor-notice { display:flex; align-items:center; gap:8px; margin:12px 24px 0; border:1px solid #c6d9ce; border-radius:9px; padding:9px 12px; background:#f5faf7; color:#35624c; font-size:12px; }
.monitor-notice span { flex:1; }.monitor-notice button { padding:3px; }
.monitor-toolbar { display:flex; align-items:center; gap:8px; padding:12px 24px; border-bottom:1px solid var(--sc-border); background:var(--sc-surface); }
.monitor-search { display:flex; width:min(360px,40vw); min-height:36px; align-items:center; gap:8px; border:1px solid var(--sc-border); border-radius:8px; padding:0 10px; background:var(--sc-surface); color:var(--sc-muted); }
.monitor-search input { min-width:0; flex:1; border:0; outline:0; background:transparent; color:var(--sc-ink); font-size:12px; }
@ -376,7 +509,7 @@ export function ScriptMonitorView({
.monitor-table-wrap { min-width:0; overflow:auto; background:var(--sc-surface); scrollbar-color:#c6cbcd transparent; scrollbar-width:thin; }
.monitor-table { width:100%; min-width:980px; border-collapse:separate; border-spacing:0; table-layout:fixed; font-size:12px; }
.monitor-table th { position:sticky; top:0; z-index:2; height:38px; border-bottom:1px solid var(--sc-border); padding:0 14px; background:var(--sc-surface-muted); color:var(--sc-muted); text-align:left; font-size:10px; font-weight:600; letter-spacing:.03em; }
.monitor-table th:nth-child(1) { width:110px; }.monitor-table th:nth-child(2) { width:25%; }.monitor-table th:nth-child(3) { width:17%; }.monitor-table th:nth-child(4) { width:14%; }.monitor-table th:nth-child(5) { width:16%; }
.monitor-table th:nth-child(1) { width:118px; }.monitor-table th:nth-child(2) { width:29%; }.monitor-table th:nth-child(3) { width:15%; }.monitor-table th:nth-child(4) { width:16%; }.monitor-table th:nth-child(5) { width:24%; }.monitor-table th:nth-child(6) { width:118px; }
.monitor-table td { height:66px; border-bottom:1px solid var(--sc-border); padding:9px 14px; vertical-align:middle; }
.monitor-table tbody tr { cursor:pointer; outline:0; transition:background .12s; }
.monitor-table tbody tr:hover, .monitor-table tbody tr:focus-visible { background:var(--sc-hover); }
@ -384,7 +517,17 @@ export function ScriptMonitorView({
.monitor-table td strong { display:block; overflow:hidden; color:var(--sc-ink); font-weight:600; text-overflow:ellipsis; white-space:nowrap; }
.monitor-table td span:not(.monitor-status):not(.monitor-connection) { display:block; margin-top:4px; overflow:hidden; color:var(--sc-muted); font-size:10px; text-overflow:ellipsis; white-space:nowrap; }
.monitor-result-summary { max-width:100%; }
.monitor-agent-line { display:flex!important; align-items:center; gap:6px; }
.monitor-agent-row, .monitor-agent-empty-row { cursor:default!important; }
.monitor-agent-row:hover, .monitor-agent-empty-row:hover { background:inherit!important; }
.monitor-agent-row td { height:46px; padding:8px 14px; background:var(--sc-surface-muted); }
.monitor-agent-summary { display:flex; align-items:center; justify-content:space-between; gap:18px; }
.monitor-agent-identity, .monitor-agent-meta { display:flex; min-width:0; align-items:center; gap:9px; }
.monitor-agent-identity strong { display:block; max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }
.monitor-agent-identity > span:not(.monitor-connection), .monitor-agent-meta span { color:var(--sc-muted); font-size:10px; }
.monitor-agent-meta { justify-content:flex-end; }.monitor-agent-meta span + span { padding-left:9px; border-left:1px solid var(--sc-border); }
.monitor-agent-empty-row td { height:54px; padding-left:34px; background:var(--sc-surface); color:var(--sc-muted); font-size:11px; }
.monitor-run-button { display:inline-flex; min-height:30px; align-items:center; justify-content:center; gap:5px; border:1px solid #2f3436; border-radius:7px; padding:0 9px; background:#2f3436; color:#fff; font-size:10px; font-weight:650; white-space:nowrap; }
.monitor-run-button:hover:not(:disabled) { background:#202426; }.monitor-run-button:disabled { border-color:var(--sc-border); background:var(--sc-surface-muted); color:var(--sc-subtle); cursor:not-allowed; }
:global(.monitor-status) { display:inline-flex; align-items:center; gap:6px; border-radius:999px; padding:4px 7px; background:#f4f5f5; color:#535b5e; font-size:10px; font-weight:600; white-space:nowrap; }
:global(.monitor-status-dot) { width:6px; height:6px; border-radius:50%; background:currentColor; }
:global(.monitor-status-success) { background:#edf4f0; color:#35624c; }
@ -394,11 +537,12 @@ export function ScriptMonitorView({
:global(.monitor-status-disabled), :global(.monitor-status-unknown) { background:#f2f3f3; color:#656d70; }
:global(.monitor-connection) { display:inline-flex; align-items:center; gap:3px; border:1px solid var(--sc-border); border-radius:999px; padding:1px 5px; color:var(--sc-muted); font-size:9px; }
:global(.monitor-connection-offline) { color:#8e4845; }
:global(.monitor-connection-pending) { color:#7b5b24; }
.monitor-empty { display:flex; min-height:360px; flex-direction:column; align-items:center; justify-content:center; padding:40px; color:var(--sc-muted); text-align:center; }
.monitor-empty h2 { margin-top:14px; color:var(--sc-ink); font-size:15px; font-weight:650; }
.monitor-empty p { max-width:460px; margin:7px 0 18px; font-size:12px; line-height:1.7; }
@media (max-width: 1000px) { .monitor-workspace.has-inspector { grid-template-columns:minmax(0,1fr); } }
@media (max-width: 760px) { .monitor-header { flex-direction:column; align-items:stretch; gap:12px; padding:16px; }.monitor-header p { max-width:none; }.monitor-header-actions { justify-content:flex-end; }.monitor-button { padding:0 10px; }.monitor-metrics { grid-template-columns:repeat(2,minmax(0,1fr)); }.monitor-metrics > div:nth-child(2) { border-right:0; }.monitor-metrics > div { min-height:58px; padding:8px 12px; }.monitor-toolbar { flex-wrap:wrap; padding:10px 16px; }.monitor-search { width:100%; }.monitor-toolbar select { flex:1; }.monitor-result-count { width:100%; margin-left:0; text-align:right; }.monitor-table { min-width:650px; }.monitor-col-secondary { display:none; } }
@media (max-width: 760px) { .monitor-header { flex-direction:column; align-items:stretch; gap:12px; padding:16px; }.monitor-header p { max-width:none; }.monitor-header-actions { justify-content:flex-end; }.monitor-button { padding:0 10px; }.monitor-metrics { grid-template-columns:repeat(2,minmax(0,1fr)); }.monitor-metrics > div:nth-child(2) { border-right:0; }.monitor-metrics > div { min-height:58px; padding:8px 12px; }.monitor-toolbar { flex-wrap:wrap; padding:10px 16px; }.monitor-search { width:100%; }.monitor-toolbar select { flex:1; }.monitor-result-count { width:100%; margin-left:0; text-align:right; }.monitor-table { min-width:720px; }.monitor-col-secondary { display:none; }.monitor-agent-summary { align-items:flex-start; flex-direction:column; gap:5px; }.monitor-agent-meta { justify-content:flex-start; } }
`}</style>
</div>
)
@ -408,23 +552,34 @@ function ScriptInspector({
script,
runs,
loading,
canEdit,
onRequestRun,
onClose,
}: {
script: ScriptMonitorScriptRow
runs: ScriptMonitorLatestRun[]
loading: boolean
canEdit: boolean
onRequestRun: () => void
onClose: () => void
}) {
const output = script.latestRun?.outputTail ?? script.lastOutputTail
const errorOutput = script.latestRun?.errorTail ?? script.lastErrorTail
const runState = manualRunState(script, canEdit)
return (
<aside className="monitor-inspector" aria-label={`${script.name} 详情`}>
<header><div><StatusBadge status={script.status} /><h2>{script.name}</h2></div><button type="button" onClick={onClose} aria-label="关闭脚本详情"><X size={17} /></button></header>
<div className="monitor-inspector-scroll">
<section><h3>任务</h3><p>{script.taskDescription || '暂未填写任务说明'}</p></section>
<section className="monitor-manual-run-section">
<div><h3>手动运行</h3><p>{runState.reason}</p></div>
<button type="button" disabled={runState.disabled} onClick={onRequestRun}>
<Play size={14} />{runState.label}
</button>
</section>
<dl>
<div><dt>运行机器</dt><dd>{script.agent.name}<small>{script.agent.hostname || '未上报主机名'}</small></dd></div>
<div><dt>网络通道</dt><dd>{script.agent.online ? (script.agent.connectionKind === 'INTRANET' ? '内网连接' : '公网连接') : '机器离线'}<small>{script.agent.lastSeenAt ? `最后心跳 ${formatDateTime(script.agent.lastSeenAt)}` : '尚无心跳'}</small></dd></div>
<div><dt>Agent 状态</dt><dd>{script.agent.online ? (script.agent.connectionKind === 'INTRANET' ? '在线 · 内网连接' : '在线 · 公网连接') : 'Agent 离线'}<small>{script.agent.lastSeenAt ? `最后心跳 ${formatDateTime(script.agent.lastSeenAt)}` : '尚无心跳'}</small></dd></div>
<div><dt>任务类型</dt><dd>{JOB_TYPE_LABELS[script.jobType]}<small>{script.schedule || '无固定计划'}</small></dd></div>
<div><dt>最近运行</dt><dd>{formatDateTime(script.lastFinishedAt ?? script.lastStartedAt)}<small>{formatDuration(script.lastDurationMs)} · {script.lastExitCode == null ? '无退出码' : `退出码 ${script.lastExitCode}`}</small></dd></div>
</dl>
@ -441,6 +596,7 @@ function ScriptInspector({
header button { border-radius:7px; padding:6px; color:var(--sc-muted); } header button:hover { background:var(--sc-hover); color:var(--sc-ink); }
.monitor-inspector-scroll { height:calc(100% - 72px); overflow:auto; scrollbar-color:#c6cbcd transparent; scrollbar-width:thin; }
section { border-bottom:1px solid var(--sc-border); padding:14px 16px; } section h3 { margin-bottom:7px; color:var(--sc-muted); font-size:10px; font-weight:650; letter-spacing:.04em; } section p { color:var(--sc-ink); font-size:12px; line-height:1.65; }
.monitor-manual-run-section { display:flex; align-items:center; justify-content:space-between; gap:14px; }.monitor-manual-run-section div { min-width:0; }.monitor-manual-run-section h3 { margin-bottom:4px; }.monitor-manual-run-section p { color:var(--sc-muted); font-size:10px; line-height:1.45; }.monitor-manual-run-section button { display:inline-flex; min-height:32px; flex:none; align-items:center; gap:6px; border:1px solid #2f3436; border-radius:7px; padding:0 10px; background:#2f3436; color:#fff; font-size:10px; font-weight:650; }.monitor-manual-run-section button:disabled { border-color:var(--sc-border); background:var(--sc-surface-muted); color:var(--sc-subtle); cursor:not-allowed; }
dl { display:grid; grid-template-columns:1fr 1fr; border-bottom:1px solid var(--sc-border); } dl div { padding:12px 16px; border-right:1px solid var(--sc-border); border-bottom:1px solid var(--sc-border); } dl div:nth-child(2n) { border-right:0; } dl div:nth-last-child(-n+2) { border-bottom:0; } dt { margin-bottom:5px; color:var(--sc-muted); font-size:10px; } dd { font-size:12px; font-weight:600; } dd small { display:block; margin-top:3px; color:var(--sc-muted); font-size:9px; font-weight:400; line-height:1.45; }
pre { max-height:180px; overflow:auto; border:1px solid var(--sc-border); border-radius:7px; padding:10px; background:var(--sc-surface-muted); color:#303638; font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:10px; line-height:1.55; white-space:pre-wrap; word-break:break-word; }
.is-error pre { border-color:#e5cbc9; background:#fff8f7; color:#7f3735; }
@ -454,6 +610,52 @@ function ScriptInspector({
)
}
function ConfirmRunDialog({
script,
submitting,
onCancel,
onConfirm,
}: {
script: ScriptMonitorScriptRow
submitting: boolean
onCancel: () => void
onConfirm: () => void
}) {
return (
<div className="monitor-confirm-backdrop" role="presentation">
<section className="monitor-confirm" role="dialog" aria-modal="true" aria-labelledby="confirm-run-title">
<header>
<div>
<h2 id="confirm-run-title">确认运行“{script.name}”</h2>
<p>执行请求将由 {script.agent.name} 的 Agent 主动领取。</p>
</div>
<button type="button" onClick={onCancel} aria-label="关闭"><X size={17} /></button>
</header>
<div className="monitor-confirm-body">
<p>平台不会下发任意命令,只会要求 Agent 执行本机配置中与此脚本对应的预存命令。</p>
{script.commandPreview ? <pre>{script.commandPreview}</pre> : null}
<footer>
<button type="button" onClick={onCancel} disabled={submitting}>取消</button>
<button type="button" className="is-primary" onClick={onConfirm} disabled={submitting}>
{submitting ? <LoaderCircle size={14} className="animate-spin" /> : <Play size={14} />}
{submitting ? '发送中' : '确认运行'}
</button>
</footer>
</div>
</section>
<style jsx>{`
.monitor-confirm-backdrop { position:fixed; inset:0; z-index:85; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(28,32,34,.28); }
.monitor-confirm { width:min(500px,100%); border:1px solid var(--sc-border); border-radius:14px; background:var(--sc-surface); box-shadow:0 18px 48px rgba(25,29,31,.18); }
header { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; border-bottom:1px solid var(--sc-border); padding:17px 18px; } header h2 { font-size:15px; font-weight:700; } header p { margin-top:5px; color:var(--sc-muted); font-size:11px; } header button { border-radius:7px; padding:5px; color:var(--sc-muted); }
.monitor-confirm-body { display:flex; flex-direction:column; gap:13px; padding:18px; }.monitor-confirm-body > p { color:var(--sc-muted); font-size:11px; line-height:1.65; }
pre { max-height:120px; overflow:auto; border:1px solid var(--sc-border); border-radius:8px; padding:9px 10px; background:var(--sc-surface-muted); color:var(--sc-ink); font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:10px; white-space:pre-wrap; word-break:break-word; }
footer { display:flex; justify-content:flex-end; gap:8px; padding-top:3px; } footer button { display:inline-flex; min-height:36px; align-items:center; justify-content:center; gap:6px; border:1px solid var(--sc-border); border-radius:8px; padding:0 12px; font-size:11px; font-weight:650; } footer button.is-primary { border-color:#2f3436; background:#2f3436; color:#fff; } footer button:disabled { opacity:.55; }
@media (max-width:620px) { .monitor-confirm-backdrop { align-items:flex-end; padding:0; }.monitor-confirm { border-radius:14px 14px 0 0; } }
`}</style>
</div>
)
}
function ConnectAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [name, setName] = useState('')
const [networkMode, setNetworkMode] = useState<ScriptMonitorNetworkMode>('AUTO')
@ -474,6 +676,7 @@ function ConnectAgentDialog({ onClose, onCreated }: { onClose: () => void; onCre
internet: internetUrl || undefined,
},
reportIntervalSec: 60,
commandPollIntervalSec: 5,
autoDiscover: { cron: true, pm2: true },
scripts: [],
}, null, 2) : ''

View File

@ -15,7 +15,9 @@ function emptyOverview(): ScriptMonitorOverview {
scripts: [],
metrics: {
agentTotal: 0,
agentConnected: 0,
agentOnline: 0,
agentPending: 0,
scriptTotal: 0,
healthy: 0,
running: 0,

View File

@ -10,12 +10,21 @@ export const SCRIPT_MONITOR_JOB_STATUSES = [
'DISABLED',
] as const
export const SCRIPT_MONITOR_RUN_STATUSES = ['RUNNING', 'SUCCESS', 'FAILED'] as const
export const SCRIPT_MONITOR_COMMAND_STATUSES = [
'PENDING',
'RUNNING',
'SUCCESS',
'FAILED',
'EXPIRED',
] as const
export type ScriptMonitorNetworkMode = typeof SCRIPT_MONITOR_NETWORK_MODES[number]
export type ScriptMonitorConnectionKind = typeof SCRIPT_MONITOR_CONNECTION_KINDS[number]
export type ScriptMonitorJobType = typeof SCRIPT_MONITOR_JOB_TYPES[number]
export type ScriptMonitorJobStatus = typeof SCRIPT_MONITOR_JOB_STATUSES[number]
export type ScriptMonitorRunStatus = typeof SCRIPT_MONITOR_RUN_STATUSES[number]
export type ScriptMonitorCommandStatus = typeof SCRIPT_MONITOR_COMMAND_STATUSES[number]
export type ScriptMonitorAgentConnectionState = 'PENDING' | 'ONLINE' | 'OFFLINE'
export type ScriptMonitorLatestRun = {
id: string
@ -29,6 +38,14 @@ export type ScriptMonitorLatestRun = {
outputTail: string | null
errorTail: string | null
}
export type ScriptMonitorLatestCommand = {
id: string
status: ScriptMonitorCommandStatus
requestedAt: string
claimedAt: string | null
finishedAt: string | null
errorMessage: string | null
}
export type ScriptMonitorScriptRow = {
id: string
externalKey: string
@ -40,6 +57,7 @@ export type ScriptMonitorScriptRow = {
expectedIntervalSec: number | null
status: ScriptMonitorJobStatus
isEnabled: boolean
canManualRun: boolean
lastStartedAt: string | null
lastFinishedAt: string | null
lastDurationMs: number | null
@ -54,10 +72,12 @@ export type ScriptMonitorScriptRow = {
hostname: string | null
networkMode: ScriptMonitorNetworkMode
connectionKind: ScriptMonitorConnectionKind | null
connectionState: ScriptMonitorAgentConnectionState
online: boolean
lastSeenAt: string | null
}
latestRun: ScriptMonitorLatestRun | null
latestCommand: ScriptMonitorLatestCommand | null
}
export type ScriptMonitorOverview = {
@ -68,6 +88,7 @@ export type ScriptMonitorOverview = {
hostname: string | null
networkMode: ScriptMonitorNetworkMode
connectionKind: ScriptMonitorConnectionKind | null
connectionState: ScriptMonitorAgentConnectionState
online: boolean
isActive: boolean
os: string | null
@ -80,7 +101,9 @@ export type ScriptMonitorOverview = {
scripts: ScriptMonitorScriptRow[]
metrics: {
agentTotal: number
agentConnected: number
agentOnline: number
agentPending: number
scriptTotal: number
healthy: number
running: number
@ -95,6 +118,14 @@ export type ScriptMonitorAgentCredentials = {
tokenHint: string
}
export type ScriptMonitorClaimedCommand = {
id: string
scriptKey: string
scriptName: string
taskDescription: string
jobType: ScriptMonitorJobType
}
export function isScriptMonitorAgentOnline(input: {
isActive: boolean
lastSeenAt: Date | string | null

View File

@ -31,6 +31,7 @@ export const scriptMonitorAgentReportSchema = z.object({
expectedIntervalSec: z.number().int().min(15).max(31_536_000).nullable().optional(),
status: z.enum(SCRIPT_MONITOR_JOB_STATUSES).default('UNKNOWN'),
isEnabled: z.boolean().default(true),
canManualRun: z.boolean().default(false),
lastStartedAt: optionalDateTime,
lastFinishedAt: optionalDateTime,
lastDurationMs: z.number().int().min(0).max(2_147_483_647).nullable().optional(),
@ -42,6 +43,7 @@ export const scriptMonitorAgentReportSchema = z.object({
}).strict()
export const scriptMonitorRunReportSchema = z.object({
commandId: optionalText(64),
scriptKey: z.string().trim().min(1).max(240),
scriptName: z.string().trim().min(1).max(180),
taskDescription: z.string().trim().max(2000).default(''),

View File

@ -1,5 +1,6 @@
import type {
Prisma,
ScriptMonitorCommandStatus,
ScriptMonitorConnectionKind,
ScriptMonitorJobStatus,
ScriptMonitorJobType,
@ -16,6 +17,10 @@ const overviewInclude = {
orderBy: { startedAt: 'desc' },
take: 1,
},
commands: {
orderBy: { requestedAt: 'desc' },
take: 1,
},
},
},
} satisfies Prisma.ScriptMonitorAgentInclude
@ -34,6 +39,7 @@ export type ScriptMonitorSnapshotRecord = {
expectedIntervalSec?: number | null
status: ScriptMonitorJobStatus
isEnabled: boolean
canManualRun: boolean
lastStartedAt?: Date | null
lastFinishedAt?: Date | null
lastDurationMs?: number | null
@ -67,6 +73,7 @@ export interface ScriptMonitorRepository {
}): Promise<void>
recordRun(input: {
agentId: string
commandId?: string | null
scriptKey: string
scriptName: string
taskDescription: string
@ -83,6 +90,35 @@ export interface ScriptMonitorRepository {
errorTail?: string | null
reportedAt: Date
}): Promise<void>
findScriptForManualRun(id: string, now: Date): Promise<{
id: string
agentId: string
canManualRun: boolean
status: ScriptMonitorJobStatus
agent: {
id: string
isActive: boolean
lastSeenAt: Date | null
reportIntervalSec: number
}
commands: Array<{ id: string; status: ScriptMonitorCommandStatus }>
} | null>
createRunCommand(input: {
agentId: string
scriptId: string
requestedById: string
requestedByName: string
expiresAt: Date
}): Promise<{ id: string; status: ScriptMonitorCommandStatus; requestedAt: Date }>
claimPendingCommand(agentId: string, now: Date): Promise<{
id: string
script: {
externalKey: string
name: string
taskDescription: string
jobType: ScriptMonitorJobType
}
} | null>
listOverview(): Promise<ScriptMonitorAgentOverviewRecord[]>
listRuns(scriptId: string, take: number): Promise<Array<{
id: string
@ -160,6 +196,7 @@ export class PrismaScriptMonitorRepository implements ScriptMonitorRepository {
expectedIntervalSec: script.expectedIntervalSec ?? null,
status: script.status,
isEnabled: script.isEnabled,
canManualRun: script.canManualRun,
lastStartedAt: script.lastStartedAt ?? null,
lastFinishedAt: script.lastFinishedAt ?? null,
lastDurationMs: script.lastDurationMs ?? null,
@ -178,6 +215,7 @@ export class PrismaScriptMonitorRepository implements ScriptMonitorRepository {
expectedIntervalSec: script.expectedIntervalSec ?? null,
status: script.status,
isEnabled: script.isEnabled,
canManualRun: script.canManualRun,
lastStartedAt: script.lastStartedAt ?? undefined,
lastFinishedAt: script.lastFinishedAt ?? undefined,
lastDurationMs: script.lastDurationMs ?? undefined,
@ -204,6 +242,7 @@ export class PrismaScriptMonitorRepository implements ScriptMonitorRepository {
async recordRun(input: {
agentId: string
commandId?: string | null
scriptKey: string
scriptName: string
taskDescription: string
@ -297,6 +336,120 @@ export class PrismaScriptMonitorRepository implements ScriptMonitorRepository {
errorTail: input.errorTail ?? null,
},
})
if (input.commandId) {
const commandStatus: ScriptMonitorCommandStatus = input.status === 'RUNNING'
? 'RUNNING'
: input.status === 'SUCCESS'
? 'SUCCESS'
: 'FAILED'
await tx.scriptMonitorCommand.updateMany({
where: {
id: input.commandId,
agentId: input.agentId,
scriptId: script.id,
},
data: {
status: commandStatus,
externalRunId: input.externalRunId,
...(input.status === 'RUNNING'
? { claimedAt: input.reportedAt }
: {
finishedAt: input.reportedAt,
errorMessage: input.status === 'FAILED'
? input.resultSummary ?? input.errorTail ?? '脚本执行失败'
: null,
}),
},
})
}
})
}
findScriptForManualRun(id: string, now: Date) {
return prisma.scriptMonitorScript.findUnique({
where: { id },
select: {
id: true,
agentId: true,
canManualRun: true,
status: true,
agent: {
select: {
id: true,
isActive: true,
lastSeenAt: true,
reportIntervalSec: true,
},
},
commands: {
where: {
OR: [
{ status: 'PENDING', expiresAt: { gt: now } },
{ status: 'RUNNING' },
],
},
orderBy: { requestedAt: 'desc' },
take: 1,
select: { id: true, status: true },
},
},
})
}
createRunCommand(input: {
agentId: string
scriptId: string
requestedById: string
requestedByName: string
expiresAt: Date
}) {
return prisma.scriptMonitorCommand.create({
data: input,
select: { id: true, status: true, requestedAt: true },
})
}
async claimPendingCommand(agentId: string, now: Date) {
return prisma.$transaction(async (tx) => {
await tx.scriptMonitorCommand.updateMany({
where: { agentId, status: 'PENDING', expiresAt: { lte: now } },
data: {
status: 'EXPIRED',
finishedAt: now,
errorMessage: 'Agent 未在有效时间内领取执行请求',
},
})
const command = await tx.scriptMonitorCommand.findFirst({
where: {
agentId,
status: 'PENDING',
expiresAt: { gt: now },
script: { canManualRun: true },
},
orderBy: { requestedAt: 'asc' },
select: {
id: true,
script: {
select: {
externalKey: true,
name: true,
taskDescription: true,
jobType: true,
},
},
},
})
if (!command) return null
const claimed = await tx.scriptMonitorCommand.updateMany({
where: {
id: command.id,
status: 'PENDING',
expiresAt: { gt: now },
},
data: { status: 'RUNNING', claimedAt: now },
})
return claimed.count === 1 ? command : null
})
}

View File

@ -157,9 +157,11 @@ export class StoreAccountManagementRepository {
operationDepartmentHead?: string
operationDirector?: string
profitCenter?: string
subject?: string
accountType: string
account: string
passwordCiphertext?: string
boundBankAccountNumberCiphertext?: string
boundPhone?: string
verificationContact?: string
jstOrderService?: string

View File

@ -6,6 +6,7 @@ import { STORE_ACCOUNT_EXPORT_SELECTION_LIMIT } from '@/lib/store-account-manage
jest.mock('@/lib/store-account-crypto', () => ({
decryptStoreAccountPassword: (value: string) => value ? `明文:${value}` : '',
decryptStoreAccountBankAccount: (value: string) => value ? `银行卡:${value}` : '',
}))
const adminUser = {
@ -23,9 +24,11 @@ const record = {
operationDepartmentHead: '部门负责人A',
operationDirector: '总监A',
profitCenter: '利润中心A',
subject: '测试主体',
accountType: 'MAIN',
account: 'shop-account',
passwordCiphertext: 'ciphertext-1',
boundBankAccountNumberCiphertext: 'bank-ciphertext-1',
boundPhone: '13800000000',
verificationContact: '对接人A',
jstOrderService: 'JST-001',
@ -57,9 +60,11 @@ describe('StoreAccountManagementExportService', () => {
'运营部门负责人',
'运营总监',
'利润中心',
'主体',
'账号类型',
'账号',
'密码',
'店铺绑定银行卡号',
'绑定手机号',
'手机号验证码对接人',
'聚水潭订单服务号',
@ -67,8 +72,9 @@ describe('StoreAccountManagementExportService', () => {
'创建时间',
'更新时间',
])
expect(worksheet.getCell('F2').value).toBe('主账号')
expect(worksheet.getCell('H2').value).toBe('明文:ciphertext-1')
expect(worksheet.getCell('G2').value).toBe('主账号')
expect(worksheet.getCell('I2').value).toBe('明文:ciphertext-1')
expect(worksheet.getCell('J2').value).toBe('银行卡:bank-ciphertext-1')
expect(payload.count).toBe(1)
expect(payload.fileName).toContain('店铺账号管理-全部-')
})

View File

@ -322,3 +322,99 @@ describe('StoreAccountManagementService import', () => {
})).rejects.toThrow('账号类型只能是主账号或子账号')
})
})
describe('StoreAccountManagementService store profiles', () => {
afterEach(() => {
jest.restoreAllMocks()
})
const user = {
id: 'admin-1',
email: 'admin@example.com',
name: '管理员',
role: 'ADMIN' as const,
status: 'ACTIVE' as const,
}
it('saves a profile only when the store prefix matches the platform', async () => {
const listProfiles = jest.fn().mockResolvedValue([])
const upsertProfile = jest.fn().mockResolvedValue({
id: 'profile-1',
storeName: '京东-测试店',
platform: 'JD',
merchantNumber: 'merchant-1',
secondaryMerchantNumber: '',
merchantStoreId: '',
companyId: '',
createdAt: new Date('2026-08-28T00:00:00.000Z'),
updatedAt: new Date('2026-08-28T00:00:00.000Z'),
})
const createLog = jest.fn().mockResolvedValue(undefined)
const service = new StoreAccountManagementService({
listProfiles,
upsertProfile,
createLog,
} as unknown as StoreAccountManagementRepository)
jest.spyOn(service, 'getFieldAccess').mockResolvedValue({
target: { view: true, edit: true, download: true },
access: {} as StoreAccountFieldAccess,
})
await expect(service.saveStoreProfile({
storeName: '京喜-测试店',
platform: 'JD',
values: {},
user,
})).rejects.toThrow('店铺资料平台必须与京东-或京喜-店铺名称一致')
await expect(service.saveStoreProfile({
storeName: '京东-测试店',
platform: 'JD',
values: { merchantNumber: 'merchant-1' },
user,
})).resolves.toEqual(expect.objectContaining({
storeName: '京东-测试店',
platform: 'JD',
merchantNumber: 'merchant-1',
}))
expect(upsertProfile).toHaveBeenCalledWith(expect.objectContaining({
storeName: '京东-测试店',
platform: 'JD',
actorId: 'admin-1',
}))
expect(createLog).toHaveBeenCalledWith(expect.objectContaining({ operation: 'PROFILE_UPDATE' }))
})
it('checks edit permission once for a multi-row profile import', async () => {
const listProfiles = jest.fn().mockResolvedValue([])
const upsertProfile = jest.fn().mockImplementation(async ({ storeName, platform, values }) => ({
id: `${platform}-${storeName}`,
storeName,
platform,
...values,
createdAt: new Date('2026-08-28T00:00:00.000Z'),
updatedAt: new Date('2026-08-28T00:00:00.000Z'),
}))
const createLog = jest.fn().mockResolvedValue(undefined)
const service = new StoreAccountManagementService({
listProfiles,
upsertProfile,
createLog,
} as unknown as StoreAccountManagementRepository)
const getFieldAccess = jest.spyOn(service, 'getFieldAccess').mockResolvedValue({
target: { view: true, edit: true, download: true },
access: {} as StoreAccountFieldAccess,
})
await expect(service.importStoreProfiles({
platform: 'JX',
rows: [
{ storeName: '京喜-一店', companyId: 'company-1' },
{ storeName: '京喜-二店', companyId: 'company-2' },
],
user,
})).resolves.toEqual({ processed: 2 })
expect(getFieldAccess).toHaveBeenCalledTimes(1)
expect(upsertProfile).toHaveBeenCalledTimes(2)
})
})

View File

@ -12,6 +12,9 @@ function repositoryMock(): jest.Mocked<ScriptMonitorRepository> {
findAgentAuthentication: jest.fn(),
applySnapshot: jest.fn(),
recordRun: jest.fn(),
findScriptForManualRun: jest.fn(),
createRunCommand: jest.fn(),
claimPendingCommand: jest.fn(),
listOverview: jest.fn(),
listRuns: jest.fn(),
}
@ -60,6 +63,7 @@ describe('ScriptMonitorService', () => {
expectedIntervalSec: 900,
status: 'SUCCESS',
isEnabled: true,
canManualRun: false,
lastFinishedAt: '2026-08-28T11:45:12.000Z',
}],
})
@ -90,4 +94,70 @@ describe('ScriptMonitorService', () => {
})).rejects.toMatchObject({ code: 'AUTH_002' })
expect(repository.applySnapshot).not.toHaveBeenCalled()
})
it('does not count credentials without a first heartbeat as connected machines', async () => {
const repository = repositoryMock()
repository.listOverview.mockResolvedValue([
{
id: 'agent-pending',
name: '待接入机器',
hostname: null,
networkMode: 'AUTO',
lastConnectionKind: null,
tokenHash: tokenHash('pending'),
tokenHint: 'ending',
isActive: true,
os: null,
agentVersion: null,
reportIntervalSec: 60,
lastSeenAt: null,
createdAt: new Date('2026-08-28T12:00:00.000Z'),
updatedAt: new Date('2026-08-28T12:00:00.000Z'),
scripts: [],
},
])
const service = new ScriptMonitorService(repository)
const result = await service.getOverview()
expect(result.metrics).toEqual(expect.objectContaining({
agentTotal: 1,
agentConnected: 0,
agentOnline: 0,
agentPending: 1,
}))
expect(result.agents[0].connectionState).toBe('PENDING')
})
it('queues a manual run only for an online explicitly enabled script', async () => {
const repository = repositoryMock()
repository.findScriptForManualRun.mockResolvedValue({
id: 'script-1',
agentId: 'agent-1',
canManualRun: true,
status: 'SUCCESS',
agent: {
id: 'agent-1',
isActive: true,
lastSeenAt: new Date(),
reportIntervalSec: 60,
},
commands: [],
})
repository.createRunCommand.mockResolvedValue({
id: 'command-1',
status: 'PENDING',
requestedAt: new Date('2026-08-28T12:00:00.000Z'),
})
const service = new ScriptMonitorService(repository)
const result = await service.requestManualRun('script-1', { id: 'user-1', name: '测试用户' })
expect(result).toEqual(expect.objectContaining({ id: 'command-1', status: 'PENDING' }))
expect(repository.createRunCommand).toHaveBeenCalledWith(expect.objectContaining({
agentId: 'agent-1',
scriptId: 'script-1',
requestedById: 'user-1',
}))
})
})

View File

@ -1,6 +1,7 @@
import { createHash, randomBytes, timingSafeEqual } from 'crypto'
import type {
ScriptMonitorAgentCredentials,
ScriptMonitorAgentConnectionState,
ScriptMonitorOverview,
} from '@/lib/supply-chain-script-monitor'
import {
@ -45,6 +46,22 @@ function serializeRun(run: {
}
}
function serializeCommand(command: {
id: string
status: 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILED' | 'EXPIRED'
requestedAt: Date
claimedAt: Date | null
finishedAt: Date | null
errorMessage: string | null
}) {
return {
...command,
requestedAt: command.requestedAt.toISOString(),
claimedAt: command.claimedAt?.toISOString() ?? null,
finishedAt: command.finishedAt?.toISOString() ?? null,
}
}
export class ScriptMonitorService {
constructor(private readonly repository: ScriptMonitorRepository = scriptMonitorRepository) {}
@ -106,6 +123,7 @@ export class ScriptMonitorService {
const reportedAt = new Date()
await this.repository.recordRun({
agentId: agent.id,
commandId: input.commandId,
scriptKey: input.scriptKey,
scriptName: input.scriptName,
taskDescription: input.taskDescription,
@ -125,11 +143,71 @@ export class ScriptMonitorService {
return { ok: true, reportedAt: reportedAt.toISOString() }
}
async claimCommand(agentId: string, token: string) {
const agent = await this.authenticateAgent(agentId, token)
const command = await this.repository.claimPendingCommand(agent.id, new Date())
return {
command: command ? {
id: command.id,
scriptKey: command.script.externalKey,
scriptName: command.script.name,
taskDescription: command.script.taskDescription,
jobType: command.script.jobType,
} : null,
}
}
async requestManualRun(
scriptId: string,
actor: { id: string; name: string },
) {
const now = new Date()
const script = await this.repository.findScriptForManualRun(scriptId, now)
if (!script) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '脚本不存在或已移除')
}
if (!script.canManualRun) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
'该脚本未开放手动运行,请在 Agent 配置中设置 allowManualRun: true',
)
}
if (!isScriptMonitorAgentOnline(script.agent, now.getTime())) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
'Agent 当前离线,无法立即运行脚本',
)
}
if (script.status === 'RUNNING' || script.commands.length > 0) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
'该脚本已有执行请求,请等待当前任务结束',
)
}
const command = await this.repository.createRunCommand({
agentId: script.agentId,
scriptId: script.id,
requestedById: actor.id,
requestedByName: actor.name,
expiresAt: new Date(now.getTime() + 5 * 60 * 1000),
})
return {
id: command.id,
status: command.status,
requestedAt: command.requestedAt.toISOString(),
}
}
async getOverview(): Promise<ScriptMonitorOverview> {
const records = await this.repository.listOverview()
const now = Date.now()
const agents = records.map((agent) => {
const online = isScriptMonitorAgentOnline(agent, now)
const connectionState: ScriptMonitorAgentConnectionState = !agent.lastSeenAt
? 'PENDING'
: online
? 'ONLINE'
: 'OFFLINE'
const abnormalCount = agent.scripts.filter((script) => {
const status = resolveScriptMonitorEffectiveStatus(script, now)
return status === 'FAILED' || status === 'SILENT'
@ -140,6 +218,7 @@ export class ScriptMonitorService {
hostname: agent.hostname,
networkMode: agent.networkMode,
connectionKind: agent.lastConnectionKind,
connectionState,
online,
isActive: agent.isActive,
os: agent.os,
@ -165,6 +244,7 @@ export class ScriptMonitorService {
expectedIntervalSec: script.expectedIntervalSec,
status: resolveScriptMonitorEffectiveStatus(script, now),
isEnabled: script.isEnabled,
canManualRun: script.canManualRun,
lastStartedAt: script.lastStartedAt?.toISOString() ?? null,
lastFinishedAt: script.lastFinishedAt?.toISOString() ?? null,
lastDurationMs: script.lastDurationMs,
@ -179,10 +259,12 @@ export class ScriptMonitorService {
hostname: resolvedAgent.hostname,
networkMode: resolvedAgent.networkMode,
connectionKind: resolvedAgent.connectionKind,
connectionState: resolvedAgent.connectionState,
online: resolvedAgent.online,
lastSeenAt: resolvedAgent.lastSeenAt,
},
latestRun: latestRun ? serializeRun(latestRun) : null,
latestCommand: script.commands[0] ? serializeCommand(script.commands[0]) : null,
}
}))
const healthy = scripts.filter((script) => script.status === 'SUCCESS').length
@ -196,7 +278,9 @@ export class ScriptMonitorService {
scripts,
metrics: {
agentTotal: agents.length,
agentConnected: agents.filter((agent) => agent.connectionState !== 'PENDING').length,
agentOnline: agents.filter((agent) => agent.online).length,
agentPending: agents.filter((agent) => agent.connectionState === 'PENDING').length,
scriptTotal: scripts.length,
healthy,
running,

View File

@ -1,7 +1,7 @@
import ExcelJS from 'exceljs'
import type { AuthUser } from '@/server/core/types'
import { AppError, ErrorCode } from '@/server/core/errors'
import { decryptStoreAccountPassword } from '@/lib/store-account-crypto'
import { decryptStoreAccountBankAccount, decryptStoreAccountPassword } from '@/lib/store-account-crypto'
import { STORE_ACCOUNT_EXPORT_SELECTION_LIMIT } from '@/lib/store-account-management'
import {
storeAccountManagementRepository,
@ -18,9 +18,11 @@ const EXPORT_COLUMNS = [
{ key: 'operationDepartmentHead', header: '运营部门负责人', width: 18 },
{ key: 'operationDirector', header: '运营总监', width: 16 },
{ key: 'profitCenter', header: '利润中心', width: 16 },
{ key: 'subject', header: '主体', width: 20 },
{ key: 'accountType', header: '账号类型', width: 12 },
{ key: 'account', header: '账号', width: 24 },
{ key: 'password', header: '密码', width: 24 },
{ key: 'boundBankAccountNumber', header: '店铺绑定银行卡号', width: 26 },
{ key: 'boundPhone', header: '绑定手机号', width: 18 },
{ key: 'verificationContact', header: '手机号验证码对接人', width: 22 },
{ key: 'jstOrderService', header: '聚水潭订单服务号', width: 22 },
@ -82,7 +84,7 @@ export class StoreAccountManagementExportService {
width: column.width,
}))
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
worksheet.autoFilter = { from: 'A1', to: 'N1' }
worksheet.autoFilter = { from: 'A1', to: 'P1' }
rows.forEach((row) => {
worksheet.addRow({
@ -91,9 +93,11 @@ export class StoreAccountManagementExportService {
operationDepartmentHead: row.operationDepartmentHead,
operationDirector: row.operationDirector,
profitCenter: row.profitCenter,
subject: row.subject,
accountType: formatAccountType(row.accountType),
account: row.account,
password: decryptStoreAccountPassword(row.passwordCiphertext),
boundBankAccountNumber: decryptStoreAccountBankAccount(row.boundBankAccountNumberCiphertext),
boundPhone: row.boundPhone,
verificationContact: row.verificationContact,
jstOrderService: row.jstOrderService,

View File

@ -578,6 +578,48 @@ export class StoreAccountManagementService {
ErrorCode.AUTH_PERMISSION_DENIED,
"当前账号没有编辑店铺资料的权限",
);
return this.persistStoreProfile(input);
}
async importStoreProfiles(input: {
platform: unknown;
rows: Array<Record<string, unknown>>;
user: AuthUser;
}) {
const { target } = await this.getFieldAccess(input.user);
if (!target.edit)
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
"当前账号没有编辑店铺资料的权限",
);
if (input.rows.length === 0)
throw new AppError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
"导入文件没有可用数据",
);
if (input.rows.length > 5000)
throw new AppError(
ErrorCode.VALIDATION_INVALID_FORMAT,
"单次最多导入5000行",
);
const platform = normalizeProfilePlatform(input.platform);
for (const row of input.rows) {
await this.persistStoreProfile({
storeName: clean(row.storeName, 240),
platform,
values: row,
user: input.user,
});
}
return { processed: input.rows.length };
}
private async persistStoreProfile(input: {
storeName: string;
platform: unknown;
values: Record<string, unknown>;
user: AuthUser;
}) {
const storeName = clean(input.storeName, 240);
if (!storeName)
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, "缺少店铺名称");
@ -598,7 +640,7 @@ export class StoreAccountManagementService {
field.key,
clean(input.values[field.key], 500),
]),
);
) as Record<(typeof STORE_ACCOUNT_PROFILE_FIELDS)[number]["key"], string>;
const next = await this.repository.upsertProfile({
storeName,
platform,