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', path: 'test-results/dashboard-task-editor.png',
fullPage: true, 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 page.keyboard.press('Escape')
await expect(newTaskDialog).toBeHidden() await expect(newTaskDialog).toBeHidden()
@@ -75,7 +96,7 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
name: '编辑任务 [e2e_echo]', name: '编辑任务 [e2e_echo]',
}) })
await expect(editor).toBeVisible() await expect(editor).toBeVisible()
const nameInput = editor.getByLabel('任务名称') const nameInput = editor.getByLabel('名称')
await nameInput.fill('E2E updated') await nameInput.fill('E2E updated')
await editor.getByRole('button', { name: '保存修改' }).click() await editor.getByRole('button', { name: '保存修改' }).click()
await expect(page.getByText('E2E updated')).toBeVisible() await expect(page.getByText('E2E updated')).toBeVisible()
+52 -16
View File
@@ -648,6 +648,12 @@ code {
max-height: 75vh; max-height: 75vh;
} }
.task-editor .modal-body {
min-height: 0;
flex: 1;
max-height: none;
}
.form-grid { .form-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); 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); grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 0.7fr);
} }
.form-grid-single {
grid-template-columns: 1fr;
}
.cron-header-row { .cron-header-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -804,7 +814,7 @@ code {
font-size: 10px; font-size: 10px;
} }
.action-panel { .dynamic-fields {
margin: 22px 0 0; margin: 22px 0 0;
border: 1px solid #eeeff2; border: 1px solid #eeeff2;
border-radius: 12px; border-radius: 12px;
@@ -812,15 +822,6 @@ code {
background: #fafafa; 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 { .collection-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -853,7 +854,7 @@ code {
} }
.argument-row { .argument-row {
grid-template-columns: 1fr auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr) auto;
} }
.key-value-row { .key-value-row {
@@ -866,11 +867,46 @@ code {
font-size: 12px; 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; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
margin: 24px -28px -24px;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
padding: 16px 28px; padding: 16px 28px;
background: #fafafb; background: #fafafb;
@@ -1205,8 +1241,7 @@ code {
padding: 18px; padding: 18px;
} }
.form-actions { .modal-footer {
margin: 24px -18px -18px;
padding: 16px 18px; padding: 16px 18px;
} }
@@ -1215,7 +1250,8 @@ code {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.key-value-row { .key-value-row,
.argument-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+10 -3
View File
@@ -126,9 +126,16 @@ describe('TaskDashboard', () => {
renderApp() renderApp()
await user.click(screen.getByRole('button', { name: '+ 新建任务' })) await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
await user.clear(screen.getByLabelText('任务标识')) await user.click(screen.getByRole('button', { name: '+ 添加参数' }))
await user.type(screen.getByLabelText('任务标识'), 'new_task') expect(
await user.type(screen.getByLabelText('任务名称'), 'New task') 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.type(screen.getByLabelText('工作目录'), 'D:\\Projects\\new')
await user.click( await user.click(
+125 -47
View File
@@ -13,6 +13,7 @@ import {
mapServerFieldToFormField, mapServerFieldToFormField,
taskFormSchema, taskFormSchema,
taskToFormValues, taskToFormValues,
type KeyValueFormValue,
type TaskFormValues, type TaskFormValues,
} from '../lib/taskForm' } from '../lib/taskForm'
import { useFocusTrap } from '../lib/useFocusTrap' import { useFocusTrap } from '../lib/useFocusTrap'
@@ -53,6 +54,7 @@ export function TaskEditorModal({
const actionType = watch('actionType') const actionType = watch('actionType')
const cronValue = watch('cron') const cronValue = watch('cron')
const timezoneValue = watch('timezone') const timezoneValue = watch('timezone')
const scriptPreview = watch('script')
const debouncedCron = useDebouncedValue(cronValue, 300) const debouncedCron = useDebouncedValue(cronValue, 300)
const debouncedTimezone = useDebouncedValue(timezoneValue, 300) const debouncedTimezone = useDebouncedValue(timezoneValue, 300)
const cronPreview = useQuery({ const cronPreview = useQuery({
@@ -169,7 +171,12 @@ export function TaskEditorModal({
</button> </button>
</header> </header>
<form className="modal-body" onSubmit={submit} noValidate> <form
id="task-editor-form"
className="modal-body"
onSubmit={submit}
noValidate
>
{submitError ? ( {submitError ? (
<div className="inline-alert inline-alert-error" role="alert"> <div className="inline-alert inline-alert-error" role="alert">
{submitError} {submitError}
@@ -177,7 +184,10 @@ export function TaskEditorModal({
) : null} ) : null}
<div className="form-grid"> <div className="form-grid">
<Field label="任务标识" error={errors.id?.message}> <Field
label="任务标识 (Task ID)"
error={errors.id?.message}
>
<input <input
{...register('id')} {...register('id')}
className="field-input mono" className="field-input mono"
@@ -187,7 +197,7 @@ export function TaskEditorModal({
aria-invalid={Boolean(errors.id)} aria-invalid={Boolean(errors.id)}
/> />
</Field> </Field>
<Field label="任务名称" error={errors.name?.message}> <Field label="名称" error={errors.name?.message}>
<input <input
{...register('name')} {...register('name')}
className="field-input" className="field-input"
@@ -251,7 +261,7 @@ export function TaskEditorModal({
</span> </span>
)} )}
</div> </div>
<Field label="超时时间(秒)" error={errors.timeout_secs?.message}> <Field label="超时时间 (秒)" error={errors.timeout_secs?.message}>
<input <input
{...register('timeout_secs', { valueAsNumber: true })} {...register('timeout_secs', { valueAsNumber: true })}
type="number" type="number"
@@ -266,7 +276,7 @@ export function TaskEditorModal({
</Field> </Field>
</div> </div>
<div className="form-grid"> <div className="form-grid form-grid-single">
<Field label="IANA 时区" error={errors.timezone?.message}> <Field label="IANA 时区" error={errors.timezone?.message}>
<input <input
{...register('timezone')} {...register('timezone')}
@@ -283,26 +293,19 @@ export function TaskEditorModal({
<option value="America/New_York" /> <option value="America/New_York" />
</datalist> </datalist>
</Field> </Field>
<Field label="任务类型"> <Field label="任务类型 (Action Type)">
<select {...register('actionType')} className="field-input"> <select {...register('actionType')} className="field-input">
<option value="script">执行脚本</option> <option value="script">
<option value="http">HTTP 请求</option> 执行脚本 (Python / Shell / Node / Lua)
<option value="process">二进制进程</option> </option>
<option value="http">发起 HTTP 请求</option>
<option value="process">管理常驻/独立二进制</option>
</select> </select>
</Field> </Field>
</div> </div>
<label className="checkbox-field">
<input type="checkbox" {...register('enabled')} />
<span>
<strong>立即激活此任务调度</strong>
<small>保存后立即注册到 Cron 调度器</small>
</span>
</label>
{actionType === 'script' ? ( {actionType === 'script' ? (
<fieldset className="action-panel"> <div className="dynamic-fields">
<legend>脚本配置</legend>
<div className="form-grid"> <div className="form-grid">
<Field <Field
label="工作目录" label="工作目录"
@@ -344,7 +347,9 @@ export function TaskEditorModal({
<button <button
type="button" type="button"
className="button button-secondary button-small" className="button button-secondary button-small"
onClick={() => scriptArgs.append({ value: '' })} onClick={() =>
scriptArgs.append({ key: '', value: '' })
}
> >
+ 添加参数 + 添加参数
</button> </button>
@@ -352,10 +357,15 @@ export function TaskEditorModal({
<div className="collection-list"> <div className="collection-list">
{scriptArgs.fields.map((field, index) => ( {scriptArgs.fields.map((field, index) => (
<div className="argument-row" key={field.id}> <div className="argument-row" key={field.id}>
<input
{...register(`script.args.${index}.key`)}
className="field-input mono"
placeholder="参数名 (如 --mode 或 -v)"
/>
<input <input
{...register(`script.args.${index}.value`)} {...register(`script.args.${index}.value`)}
className="field-input mono" className="field-input mono"
placeholder="例如:--mode" placeholder="参数值 (可选,无值可留空)"
/> />
<button <button
type="button" type="button"
@@ -385,12 +395,38 @@ export function TaskEditorModal({
onAdd={() => scriptEnv.append({ key: '', value: '' })} onAdd={() => scriptEnv.append({ key: '', value: '' })}
onRemove={scriptEnv.remove} 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} ) : null}
{actionType === 'http' ? ( {actionType === 'http' ? (
<fieldset className="action-panel"> <div className="dynamic-fields">
<legend>HTTP 配置</legend>
<div className="form-grid"> <div className="form-grid">
<Field label="请求 URL" error={errors.http?.url?.message}> <Field label="请求 URL" error={errors.http?.url?.message}>
<input <input
@@ -436,12 +472,11 @@ export function TaskEditorModal({
onAdd={() => httpHeaders.append({ key: '', value: '' })} onAdd={() => httpHeaders.append({ key: '', value: '' })}
onRemove={httpHeaders.remove} onRemove={httpHeaders.remove}
/> />
</fieldset> </div>
) : null} ) : null}
{actionType === 'process' ? ( {actionType === 'process' ? (
<fieldset className="action-panel"> <div className="dynamic-fields">
<legend>进程配置</legend>
<div className="form-grid"> <div className="form-grid">
<Field <Field
label="可执行程序" label="可执行程序"
@@ -469,7 +504,9 @@ export function TaskEditorModal({
<button <button
type="button" type="button"
className="button button-secondary button-small" className="button button-secondary button-small"
onClick={() => processArgs.append({ value: '' })} onClick={() =>
processArgs.append({ key: '', value: '' })
}
> >
+ 添加参数 + 添加参数
</button> </button>
@@ -477,10 +514,15 @@ export function TaskEditorModal({
<div className="collection-list"> <div className="collection-list">
{processArgs.fields.map((field, index) => ( {processArgs.fields.map((field, index) => (
<div className="argument-row" key={field.id}> <div className="argument-row" key={field.id}>
<input
{...register(`process.args.${index}.key`)}
className="field-input mono"
placeholder="参数名 (如 --port)"
/>
<input <input
{...register(`process.args.${index}.value`)} {...register(`process.args.${index}.value`)}
className="field-input mono" className="field-input mono"
placeholder="例如:--port" placeholder="参数值 (可选)"
/> />
<button <button
type="button" type="button"
@@ -509,32 +551,68 @@ export function TaskEditorModal({
onAdd={() => processEnv.append({ key: '', value: '' })} onAdd={() => processEnv.append({ key: '', value: '' })}
onRemove={processEnv.remove} onRemove={processEnv.remove}
/> />
</fieldset> </div>
) : null} ) : null}
<footer className="form-actions"> <label className="checkbox-field">
<button <input type="checkbox" {...register('enabled')} />
type="button" <span>
className="button button-secondary" <strong>立即激活此任务调度</strong>
onClick={onClose} <small>保存后立即注册到 Cron 调度器</small>
disabled={saving} </span>
> </label>
取消
</button>
<button
type="submit"
className="button button-primary"
disabled={saving}
>
{saving ? '保存中...' : task ? '保存修改' : '保存并热刷新'}
</button>
</footer>
</form> </form>
<footer className="modal-footer">
<button
type="button"
className="button button-secondary"
onClick={onClose}
disabled={saving}
>
取消
</button>
<button
type="submit"
form="task-editor-form"
className="button button-primary"
disabled={saving}
>
{saving ? '保存中...' : task ? '保存修改' : '保存并热刷新'}
</button>
</footer>
</section> </section>
</div> </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 { interface FieldProps {
label: string label: string
error?: string error?: string
+1 -1
View File
@@ -35,7 +35,7 @@ describe('task form payload', () => {
values.name = 'Worker' values.name = 'Worker'
values.actionType = 'process' values.actionType = 'process'
values.process.executable = 'C:\\Tools\\worker.exe' 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' }] values.process.env = [{ key: 'WORKER_MODE', value: 'local' }]
const payload = valuesToCreateRequest(values) const payload = valuesToCreateRequest(values)
+25 -14
View File
@@ -12,10 +12,6 @@ export interface KeyValueFormValue {
value: string value: string
} }
export interface ArgumentFormValue {
value: string
}
export interface TaskFormValues { export interface TaskFormValues {
id: string id: string
name: string name: string
@@ -28,7 +24,7 @@ export interface TaskFormValues {
working_dir: string working_dir: string
interpreter: string interpreter: string
script: string script: string
args: ArgumentFormValue[] args: KeyValueFormValue[]
env: KeyValueFormValue[] env: KeyValueFormValue[]
} }
http: { http: {
@@ -41,7 +37,7 @@ export interface TaskFormValues {
process: { process: {
executable: string executable: string
working_dir: string working_dir: string
args: ArgumentFormValue[] args: KeyValueFormValue[]
env: KeyValueFormValue[] env: KeyValueFormValue[]
} }
} }
@@ -92,7 +88,7 @@ export const taskFormSchema = z
working_dir: z.string(), working_dir: z.string(),
interpreter: z.string(), interpreter: z.string(),
script: z.string(), script: z.string(),
args: z.array(z.object({ value: z.string() })), args: z.array(keyValueSchema),
env: z.array(keyValueSchema), env: z.array(keyValueSchema),
}), }),
http: z.object({ http: z.object({
@@ -105,7 +101,7 @@ export const taskFormSchema = z
process: z.object({ process: z.object({
executable: z.string(), executable: z.string(),
working_dir: z.string(), working_dir: z.string(),
args: z.array(z.object({ value: z.string() })), args: z.array(keyValueSchema),
env: z.array(keyValueSchema), env: z.array(keyValueSchema),
}), }),
}) })
@@ -208,7 +204,7 @@ export function taskToFormValues(task: Task): TaskFormValues {
working_dir: task.action.working_dir, working_dir: task.action.working_dir,
interpreter: task.action.interpreter, interpreter: task.action.interpreter,
script: task.action.script, 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]) => ({ env: Object.entries(task.action.env ?? {}).map(([key, value]) => ({
key, key,
value, value,
@@ -228,7 +224,7 @@ export function taskToFormValues(task: Task): TaskFormValues {
values.process = { values.process = {
executable: task.action.executable, executable: task.action.executable,
working_dir: task.action.working_dir ?? '', 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]) => ({ env: Object.entries(task.action.env ?? {}).map(([key, value]) => ({
key, key,
value, value,
@@ -246,7 +242,7 @@ export function valuesToAction(values: TaskFormValues): TaskAction {
working_dir: values.script.working_dir.trim(), working_dir: values.script.working_dir.trim(),
interpreter: values.script.interpreter.trim(), interpreter: values.script.interpreter.trim(),
script: values.script.script.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), env: keyValuesToObject(values.script.env),
} }
} }
@@ -271,7 +267,7 @@ export function valuesToAction(values: TaskFormValues): TaskAction {
type: 'process', type: 'process',
executable: values.process.executable.trim(), executable: values.process.executable.trim(),
working_dir: values.process.working_dir.trim() || null, 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), env: keyValuesToObject(values.process.env),
} }
} }
@@ -318,8 +314,23 @@ export function mapServerFieldToFormField(
return `${actionType}.${actionField}` return `${actionType}.${actionField}`
} }
function cleanList(values: string[]): string[] { function argumentsToArray(values: KeyValueFormValue[]): string[] {
return values.map((value) => value.trim()).filter(Boolean) 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( function keyValuesToObject(