feat: 修改前端显示

This commit is contained in:
2026-10-18 22:52:18 +08:00
parent 8ce02af13a
commit 00a608c149
6 changed files with 235 additions and 82 deletions
+22 -1
View File
@@ -56,6 +56,27 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
path: 'test-results/dashboard-task-editor.png',
fullPage: true,
})
const addArgumentButton = newTaskDialog.getByRole('button', {
name: '+ 添加参数',
})
await addArgumentButton.focus()
await page.keyboard.press('Enter')
await newTaskDialog
.getByPlaceholder('参数名 (如 --mode 或 -v)')
.fill('--mode')
await newTaskDialog
.getByPlaceholder('参数值 (可选,无值可留空)')
.fill('full')
await newTaskDialog
.getByText('调用预览 (Runtime Preview)')
.scrollIntoViewIfNeeded()
await expect(
newTaskDialog.getByText('--mode full', { exact: false }),
).toBeVisible()
await page.screenshot({
path: 'test-results/dashboard-task-editor-runtime.png',
fullPage: true,
})
await page.keyboard.press('Escape')
await expect(newTaskDialog).toBeHidden()
@@ -75,7 +96,7 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
name: '编辑任务 [e2e_echo]',
})
await expect(editor).toBeVisible()
const nameInput = editor.getByLabel('任务名称')
const nameInput = editor.getByLabel('名称')
await nameInput.fill('E2E updated')
await editor.getByRole('button', { name: '保存修改' }).click()
await expect(page.getByText('E2E updated')).toBeVisible()
+52 -16
View File
@@ -648,6 +648,12 @@ code {
max-height: 75vh;
}
.task-editor .modal-body {
min-height: 0;
flex: 1;
max-height: none;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -659,6 +665,10 @@ code {
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 0.7fr);
}
.form-grid-single {
grid-template-columns: 1fr;
}
.cron-header-row {
display: flex;
align-items: center;
@@ -804,7 +814,7 @@ code {
font-size: 10px;
}
.action-panel {
.dynamic-fields {
margin: 22px 0 0;
border: 1px solid #eeeff2;
border-radius: 12px;
@@ -812,15 +822,6 @@ code {
background: #fafafa;
}
.action-panel legend {
padding: 0 8px;
color: #374151;
font-family: var(--font-sans);
font-size: 13px;
font-weight: 600;
letter-spacing: 0;
}
.collection-header {
display: flex;
align-items: flex-start;
@@ -853,7 +854,7 @@ code {
}
.argument-row {
grid-template-columns: 1fr auto;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr) auto;
}
.key-value-row {
@@ -866,11 +867,46 @@ code {
font-size: 12px;
}
.form-actions {
.cli-preview {
margin-top: 18px;
border-radius: 8px;
padding: 14px 18px;
background: #18191c;
}
.cli-preview-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
border-bottom: 1px solid #282a30;
padding-bottom: 6px;
color: #71767f;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.cli-preview-body {
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
}
.cli-line-env {
margin-bottom: 4px;
color: #9da3af;
}
.cli-line-cmd {
color: #34d399;
font-weight: 500;
overflow-wrap: anywhere;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin: 24px -28px -24px;
border-top: 1px solid var(--border);
padding: 16px 28px;
background: #fafafb;
@@ -1205,8 +1241,7 @@ code {
padding: 18px;
}
.form-actions {
margin: 24px -18px -18px;
.modal-footer {
padding: 16px 18px;
}
@@ -1215,7 +1250,8 @@ code {
grid-template-columns: 1fr;
}
.key-value-row {
.key-value-row,
.argument-row {
grid-template-columns: 1fr;
}
+10 -3
View File
@@ -126,9 +126,16 @@ describe('TaskDashboard', () => {
renderApp()
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
await user.clear(screen.getByLabelText('任务标识'))
await user.type(screen.getByLabelText('任务标识'), 'new_task')
await user.type(screen.getByLabelText('任务名称'), 'New task')
await user.click(screen.getByRole('button', { name: '+ 添加参数' }))
expect(
screen.getByPlaceholderText('参数名 (如 --mode 或 -v)'),
).toBeInTheDocument()
expect(
screen.getByPlaceholderText('参数值 (可选,无值可留空)'),
).toBeInTheDocument()
await user.clear(screen.getByLabelText(/任务标识/))
await user.type(screen.getByLabelText(/任务标识/), 'new_task')
await user.type(screen.getByLabelText('名称'), 'New task')
await user.type(screen.getByLabelText('工作目录'), 'D:\\Projects\\new')
await user.click(
+110 -32
View File
@@ -13,6 +13,7 @@ import {
mapServerFieldToFormField,
taskFormSchema,
taskToFormValues,
type KeyValueFormValue,
type TaskFormValues,
} from '../lib/taskForm'
import { useFocusTrap } from '../lib/useFocusTrap'
@@ -53,6 +54,7 @@ export function TaskEditorModal({
const actionType = watch('actionType')
const cronValue = watch('cron')
const timezoneValue = watch('timezone')
const scriptPreview = watch('script')
const debouncedCron = useDebouncedValue(cronValue, 300)
const debouncedTimezone = useDebouncedValue(timezoneValue, 300)
const cronPreview = useQuery({
@@ -169,7 +171,12 @@ export function TaskEditorModal({
</button>
</header>
<form className="modal-body" onSubmit={submit} noValidate>
<form
id="task-editor-form"
className="modal-body"
onSubmit={submit}
noValidate
>
{submitError ? (
<div className="inline-alert inline-alert-error" role="alert">
{submitError}
@@ -177,7 +184,10 @@ export function TaskEditorModal({
) : null}
<div className="form-grid">
<Field label="任务标识" error={errors.id?.message}>
<Field
label="任务标识 (Task ID)"
error={errors.id?.message}
>
<input
{...register('id')}
className="field-input mono"
@@ -187,7 +197,7 @@ export function TaskEditorModal({
aria-invalid={Boolean(errors.id)}
/>
</Field>
<Field label="任务名称" error={errors.name?.message}>
<Field label="名称" error={errors.name?.message}>
<input
{...register('name')}
className="field-input"
@@ -251,7 +261,7 @@ export function TaskEditorModal({
</span>
)}
</div>
<Field label="超时时间(秒)" error={errors.timeout_secs?.message}>
<Field label="超时时间 (秒)" error={errors.timeout_secs?.message}>
<input
{...register('timeout_secs', { valueAsNumber: true })}
type="number"
@@ -266,7 +276,7 @@ export function TaskEditorModal({
</Field>
</div>
<div className="form-grid">
<div className="form-grid form-grid-single">
<Field label="IANA 时区" error={errors.timezone?.message}>
<input
{...register('timezone')}
@@ -283,26 +293,19 @@ export function TaskEditorModal({
<option value="America/New_York" />
</datalist>
</Field>
<Field label="任务类型">
<Field label="任务类型 (Action Type)">
<select {...register('actionType')} className="field-input">
<option value="script">执行脚本</option>
<option value="http">HTTP 请求</option>
<option value="process">二进制进程</option>
<option value="script">
执行脚本 (Python / Shell / Node / Lua)
</option>
<option value="http">发起 HTTP 请求</option>
<option value="process">管理常驻/独立二进制</option>
</select>
</Field>
</div>
<label className="checkbox-field">
<input type="checkbox" {...register('enabled')} />
<span>
<strong>立即激活此任务调度</strong>
<small>保存后立即注册到 Cron 调度器</small>
</span>
</label>
{actionType === 'script' ? (
<fieldset className="action-panel">
<legend>脚本配置</legend>
<div className="dynamic-fields">
<div className="form-grid">
<Field
label="工作目录"
@@ -344,7 +347,9 @@ export function TaskEditorModal({
<button
type="button"
className="button button-secondary button-small"
onClick={() => scriptArgs.append({ value: '' })}
onClick={() =>
scriptArgs.append({ key: '', value: '' })
}
>
+ 添加参数
</button>
@@ -352,10 +357,15 @@ export function TaskEditorModal({
<div className="collection-list">
{scriptArgs.fields.map((field, index) => (
<div className="argument-row" key={field.id}>
<input
{...register(`script.args.${index}.key`)}
className="field-input mono"
placeholder="参数名 (如 --mode 或 -v)"
/>
<input
{...register(`script.args.${index}.value`)}
className="field-input mono"
placeholder="例如:--mode"
placeholder="参数值 (可选,无值可留空)"
/>
<button
type="button"
@@ -385,12 +395,38 @@ export function TaskEditorModal({
onAdd={() => scriptEnv.append({ key: '', value: '' })}
onRemove={scriptEnv.remove}
/>
</fieldset>
<div className="cli-preview">
<div className="cli-preview-header">
<span>调用预览 (Runtime Preview)</span>
<span>Windows 原生环境</span>
</div>
<div className="cli-preview-body">
{scriptPreview.env
.filter((item) => item.key.trim())
.map((item, index) => (
<div
className="cli-line-env"
key={`${item.key}-${index}`}
>
set {item.key.trim()}=
{isSensitiveName(item.key)
? '[REDACTED]'
: item.value || '""'}
</div>
))}
<div className="cli-line-cmd">
{scriptPreview.interpreter.trim() || 'python.exe'}{' '}
{scriptPreview.script.trim() || 'main.py'}
{formatArgumentPreview(scriptPreview.args)}
</div>
</div>
</div>
</div>
) : null}
{actionType === 'http' ? (
<fieldset className="action-panel">
<legend>HTTP 配置</legend>
<div className="dynamic-fields">
<div className="form-grid">
<Field label="请求 URL" error={errors.http?.url?.message}>
<input
@@ -436,12 +472,11 @@ export function TaskEditorModal({
onAdd={() => httpHeaders.append({ key: '', value: '' })}
onRemove={httpHeaders.remove}
/>
</fieldset>
</div>
) : null}
{actionType === 'process' ? (
<fieldset className="action-panel">
<legend>进程配置</legend>
<div className="dynamic-fields">
<div className="form-grid">
<Field
label="可执行程序"
@@ -469,7 +504,9 @@ export function TaskEditorModal({
<button
type="button"
className="button button-secondary button-small"
onClick={() => processArgs.append({ value: '' })}
onClick={() =>
processArgs.append({ key: '', value: '' })
}
>
+ 添加参数
</button>
@@ -477,10 +514,15 @@ export function TaskEditorModal({
<div className="collection-list">
{processArgs.fields.map((field, index) => (
<div className="argument-row" key={field.id}>
<input
{...register(`process.args.${index}.key`)}
className="field-input mono"
placeholder="参数名 (如 --port)"
/>
<input
{...register(`process.args.${index}.value`)}
className="field-input mono"
placeholder="例如:--port"
placeholder="参数值 (可选)"
/>
<button
type="button"
@@ -509,10 +551,19 @@ export function TaskEditorModal({
onAdd={() => processEnv.append({ key: '', value: '' })}
onRemove={processEnv.remove}
/>
</fieldset>
</div>
) : null}
<footer className="form-actions">
<label className="checkbox-field">
<input type="checkbox" {...register('enabled')} />
<span>
<strong>立即激活此任务调度</strong>
<small>保存后立即注册到 Cron 调度器</small>
</span>
</label>
</form>
<footer className="modal-footer">
<button
type="button"
className="button button-secondary"
@@ -523,18 +574,45 @@ export function TaskEditorModal({
</button>
<button
type="submit"
form="task-editor-form"
className="button button-primary"
disabled={saving}
>
{saving ? '保存中...' : task ? '保存修改' : '保存并热刷新'}
</button>
</footer>
</form>
</section>
</div>
)
}
function formatArgumentPreview(argumentsList: KeyValueFormValue[]): string {
const values = argumentsList.flatMap(({ key, value }) => {
const argument = key.trim()
const argumentValue = value.trim()
return [
argument,
argumentValue.includes(' ') ? `"${argumentValue}"` : argumentValue,
].filter(Boolean)
})
return values.length ? ` ${values.join(' ')}` : ''
}
function isSensitiveName(name: string): boolean {
const normalized = name.toUpperCase()
return [
'SECRET',
'TOKEN',
'PASSWORD',
'PASS',
'API_KEY',
'PRIVATE_KEY',
'CREDENTIAL',
'AUTHORIZATION',
'AUTH',
].some((marker) => normalized.includes(marker))
}
interface FieldProps {
label: string
error?: string
+1 -1
View File
@@ -35,7 +35,7 @@ describe('task form payload', () => {
values.name = 'Worker'
values.actionType = 'process'
values.process.executable = 'C:\\Tools\\worker.exe'
values.process.args = [{ value: '--port' }, { value: '8080' }]
values.process.args = [{ key: '--port', value: '8080' }]
values.process.env = [{ key: 'WORKER_MODE', value: 'local' }]
const payload = valuesToCreateRequest(values)
+25 -14
View File
@@ -12,10 +12,6 @@ export interface KeyValueFormValue {
value: string
}
export interface ArgumentFormValue {
value: string
}
export interface TaskFormValues {
id: string
name: string
@@ -28,7 +24,7 @@ export interface TaskFormValues {
working_dir: string
interpreter: string
script: string
args: ArgumentFormValue[]
args: KeyValueFormValue[]
env: KeyValueFormValue[]
}
http: {
@@ -41,7 +37,7 @@ export interface TaskFormValues {
process: {
executable: string
working_dir: string
args: ArgumentFormValue[]
args: KeyValueFormValue[]
env: KeyValueFormValue[]
}
}
@@ -92,7 +88,7 @@ export const taskFormSchema = z
working_dir: z.string(),
interpreter: z.string(),
script: z.string(),
args: z.array(z.object({ value: z.string() })),
args: z.array(keyValueSchema),
env: z.array(keyValueSchema),
}),
http: z.object({
@@ -105,7 +101,7 @@ export const taskFormSchema = z
process: z.object({
executable: z.string(),
working_dir: z.string(),
args: z.array(z.object({ value: z.string() })),
args: z.array(keyValueSchema),
env: z.array(keyValueSchema),
}),
})
@@ -208,7 +204,7 @@ export function taskToFormValues(task: Task): TaskFormValues {
working_dir: task.action.working_dir,
interpreter: task.action.interpreter,
script: task.action.script,
args: (task.action.args ?? []).map((value) => ({ value })),
args: argsToRows(task.action.args ?? []),
env: Object.entries(task.action.env ?? {}).map(([key, value]) => ({
key,
value,
@@ -228,7 +224,7 @@ export function taskToFormValues(task: Task): TaskFormValues {
values.process = {
executable: task.action.executable,
working_dir: task.action.working_dir ?? '',
args: (task.action.args ?? []).map((value) => ({ value })),
args: argsToRows(task.action.args ?? []),
env: Object.entries(task.action.env ?? {}).map(([key, value]) => ({
key,
value,
@@ -246,7 +242,7 @@ export function valuesToAction(values: TaskFormValues): TaskAction {
working_dir: values.script.working_dir.trim(),
interpreter: values.script.interpreter.trim(),
script: values.script.script.trim(),
args: cleanList(values.script.args.map((argument) => argument.value)),
args: argumentsToArray(values.script.args),
env: keyValuesToObject(values.script.env),
}
}
@@ -271,7 +267,7 @@ export function valuesToAction(values: TaskFormValues): TaskAction {
type: 'process',
executable: values.process.executable.trim(),
working_dir: values.process.working_dir.trim() || null,
args: cleanList(values.process.args.map((argument) => argument.value)),
args: argumentsToArray(values.process.args),
env: keyValuesToObject(values.process.env),
}
}
@@ -318,8 +314,23 @@ export function mapServerFieldToFormField(
return `${actionType}.${actionField}`
}
function cleanList(values: string[]): string[] {
return values.map((value) => value.trim()).filter(Boolean)
function argumentsToArray(values: KeyValueFormValue[]): string[] {
return values.flatMap(({ key, value }) => {
const argument = key.trim()
const argumentValue = value.trim()
return [argument, argumentValue].filter(Boolean)
})
}
function argsToRows(values: string[]): KeyValueFormValue[] {
const rows: KeyValueFormValue[] = []
for (let index = 0; index < values.length; index += 2) {
rows.push({
key: values[index] ?? '',
value: values[index + 1] ?? '',
})
}
return rows
}
function keyValuesToObject(