Isolate scheduled tasks by empNo, aligned with uds-auth workspaces.

Super/fallback admins see all jobs grouped by user folder; admin/user only see their own. Stamp ownership on create, filter HTTP/tools, and prefer the owner's provisioned cwd on fire.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-08 15:49:54 +08:00
parent 0ffc48b43a
commit 7d27179e51
12 changed files with 785 additions and 141 deletions

View file

@ -191,6 +191,7 @@ window.__ModuleLoader__.load({
catalog: { groups: [], current: null },
presets: { items: [], current: null },
imCatalog: { available: true, options: [], loading: false },
viewer: null,
}
function jobStamp(rows) {
@ -332,7 +333,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
newJob: '新建任务', name: '名称', prompt: '提示词', kind: '日程',
cron: 'Cron(循环)', at: '一次性时间', expr: 'Cron 表达式', atTime: '时间',
create: '创建', pause: '暂停', resume: '恢复', runNow: '立即运行', remove: '删除',
emptyJobs: '还没有定时任务。点右上角 + 新建。', next: '下次', last: '上次',
emptyJobs: '还没有定时任务。点右上角 + 新建。', unassignedOwner: '未归属', ownerClaim: '改派归属', next: '下次', last: '上次',
expandRuns: '展开记录', collapseRuns: '收起记录',
search: '搜索', searchPlaceholder: '搜索任务', searchClear: '清除搜索', searchEmpty: '没有匹配的任务。',
paused: '已暂停',
@ -373,7 +374,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
newJob: 'New job', name: 'Name', prompt: 'Prompt', kind: 'Schedule',
cron: 'Cron (recurring)', at: 'One-shot time', expr: 'Cron expression', atTime: 'Time',
create: 'Create', pause: 'Pause', resume: 'Resume', runNow: 'Run now', remove: 'Delete',
emptyJobs: 'No jobs yet. Use + to create one.', next: 'Next', last: 'Last',
emptyJobs: 'No jobs yet. Use + to create one.', unassignedOwner: 'Unassigned', ownerClaim: 'Reassign owner', next: 'Next', last: 'Last',
expandRuns: 'Show runs', collapseRuns: 'Hide runs',
search: 'Search', searchPlaceholder: 'Search jobs', searchClear: 'Clear search', searchEmpty: 'No matching jobs.',
paused: 'Paused',
@ -696,12 +697,13 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
return ''
}
function JobList({ t, jobs, runs, selection, expanded, onSelectJob, onSelectRun, onNew, onRun, onToggle, onRemove, onToggleGroup }) {
function JobList({ t, jobs, runs, selection, expanded, viewer, onSelectJob, onSelectRun, onNew, onRun, onToggle, onRemove, onToggleGroup }) {
const skin = workspaceSkin()
const [query, setQuery] = useState('')
const [searchOn, setSearchOn] = useState(false)
const searchRef = useRef(null)
const needle = query.trim().toLowerCase()
const canViewAll = !!viewer?.canViewAll
const visibleJobs = !needle ? jobs : jobs.filter((job) => {
const hay = [
job.name,
@ -710,10 +712,140 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
job.schedule?.at,
job.provider,
job.model,
job.ownerEmpNo,
job.ownerDisplayName,
...(runs.filter((run) => run.jobId === job.id).map((run) => run.summary || run.status)),
].join(' ').toLowerCase()
return hay.includes(needle)
})
function ownerKey(job) {
return String(job?.ownerEmpNo || '__unassigned__')
}
function ownerLabel(job) {
const key = ownerKey(job)
if (key === '__unassigned__') return t('unassignedOwner')
const name = String(job?.ownerDisplayName || '').trim()
return name && name !== key ? `${key} · ${name}` : key
}
function renderJobGroup(job) {
const open = expanded[job.id] === true || !!needle
const paused = job.enabled === false
const jobRuns = runs.filter((run) => run.jobId === job.id)
const selectedJob = selection?.type === 'job' && selection.jobId === job.id
const containsCurrent = selectedJob || (selection?.type === 'run' && selection.jobId === job.id)
return h('div', { className: skin.group, key: job.id },
h('div', {
className: skin.project,
role: 'treeitem',
'aria-expanded': open,
'data-on': selectedJob ? 'true' : 'false',
'data-paused': paused ? 'true' : 'false',
onClick: () => onSelectJob(job.id),
},
h('span', {
className: joinClass(skin.slot, skin.folder, open && containsCurrent ? skin.folderOn : ''),
'data-on': open && containsCurrent ? 'true' : 'false',
}, folderIcon(open)),
h('button', {
type: 'button',
className: joinClass(skin.slot, skin.chevron, 'dsh-ct-chevronHit'),
title: open ? t('collapseRuns') : t('expandRuns'),
onClick: (e) => { e.stopPropagation(); onToggleGroup(job.id) },
},
h('span', { className: joinClass(skin.arrow, open ? skin.arrowOpen : '') },
chevronIcon() || '▸')),
h('span', { className: skin.projectText },
h('span', { className: skin.title }, paused
? `${job.name} · ${t('paused')}`
: `${job.name} · ${jobDeliveryLabel(job, t)}${jobModelLabel(job) ? ` · ${jobModelLabel(job)}` : ''}`)),
h('span', { className: skin.rowActs, onClick: (e) => e.stopPropagation() },
h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) },
playIcon() || '▶'),
h('button', {
type: 'button',
className: skin.rowIcon,
title: paused ? t('resume') : t('pause'),
onClick: () => onToggle(job),
}, pauseIcon() || (paused ? '▶' : '⏸')),
h('button', { type: 'button', className: skin.rowIcon, title: t('remove'), onClick: () => onRemove(job.id) },
trashIcon() || '×'),
),
),
h('div', { className: 'dsh-ct-runs', 'data-open': open ? 'true' : 'false' },
h('div', { className: 'dsh-ct-runsInner' },
jobRuns.map((run) => {
const selectedRun = selection?.type === 'run' && selection.runId === run.id
return h('div', {
key: run.id,
className: skin.session,
role: 'treeitem',
'data-on': selectedRun ? 'true' : 'false',
onClick: () => onSelectRun(job.id, run),
},
h('span', { className: skin.slot }, sessionIcon() || '·'),
h('span', { className: skin.title }, (run.summary || run.status || '').split('\n')[0] || run.status),
h('span', { className: skin.time }, formatTime(run.actualAt || run.scheduledAt, job.schedule?.timezone)),
)
}),
),
),
)
}
function renderGrouped() {
const groups = new Map()
for (const job of visibleJobs) {
const key = ownerKey(job)
if (!groups.has(key)) groups.set(key, { key, label: ownerLabel(job), jobs: [] })
groups.get(key).jobs.push(job)
}
const ordered = [...groups.values()].sort((a, b) => {
if (a.key === '__unassigned__') return 1
if (b.key === '__unassigned__') return -1
return String(a.label).localeCompare(String(b.label), 'zh')
})
return ordered.map((group) => {
const folderId = `owner:${group.key}`
const open = expanded[folderId] !== false || !!needle
const containsCurrent = group.jobs.some((job) => (
(selection?.type === 'job' && selection.jobId === job.id)
|| (selection?.type === 'run' && selection.jobId === job.id)
))
return h('div', { className: skin.group, key: folderId },
h('div', {
className: skin.project,
role: 'treeitem',
'aria-expanded': open,
'data-on': containsCurrent ? 'true' : 'false',
onClick: () => onToggleGroup(folderId),
},
h('span', {
className: joinClass(skin.slot, skin.folder, open && containsCurrent ? skin.folderOn : ''),
'data-on': open && containsCurrent ? 'true' : 'false',
}, folderIcon(open)),
h('button', {
type: 'button',
className: joinClass(skin.slot, skin.chevron, 'dsh-ct-chevronHit'),
title: open ? t('collapseRuns') : t('expandRuns'),
onClick: (e) => { e.stopPropagation(); onToggleGroup(folderId) },
},
h('span', { className: joinClass(skin.arrow, open ? skin.arrowOpen : '') },
chevronIcon() || '▸')),
h('span', { className: skin.projectText },
h('span', { className: skin.title }, `${group.label} · ${group.jobs.length}`)),
),
h('div', { className: 'dsh-ct-runs', 'data-open': open ? 'true' : 'false' },
h('div', { className: 'dsh-ct-runsInner' },
group.jobs.map((job) => renderJobGroup(job)),
),
),
)
})
}
return h(React.Fragment, null,
h('div', { className: skin.sectionHeader },
h('span', { className: joinClass(skin.sectionLabel, searchOn ? skin.sectionLabelHidden : '') }, t('title')),
@ -768,71 +900,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
h('div', { className: skin.list },
jobs.length === 0 ? h('p', { className: skin.empty }, t('emptyJobs'))
: visibleJobs.length === 0 ? h('p', { className: skin.empty }, t('searchEmpty'))
: visibleJobs.map((job) => {
const open = expanded[job.id] === true || !!needle
const paused = job.enabled === false
const jobRuns = runs.filter((run) => run.jobId === job.id)
const selectedJob = selection?.type === 'job' && selection.jobId === job.id
const containsCurrent = selectedJob || (selection?.type === 'run' && selection.jobId === job.id)
return h('div', { className: skin.group, key: job.id },
h('div', {
className: skin.project,
role: 'treeitem',
'aria-expanded': open,
'data-on': selectedJob ? 'true' : 'false',
'data-paused': paused ? 'true' : 'false',
onClick: () => onSelectJob(job.id),
},
h('span', {
className: joinClass(skin.slot, skin.folder, open && containsCurrent ? skin.folderOn : ''),
'data-on': open && containsCurrent ? 'true' : 'false',
}, folderIcon(open)),
h('button', {
type: 'button',
className: joinClass(skin.slot, skin.chevron, 'dsh-ct-chevronHit'),
title: open ? t('collapseRuns') : t('expandRuns'),
onClick: (e) => { e.stopPropagation(); onToggleGroup(job.id) },
},
h('span', { className: joinClass(skin.arrow, open ? skin.arrowOpen : '') },
chevronIcon() || '▸')),
h('span', { className: skin.projectText },
h('span', { className: skin.title }, paused
? `${job.name} · ${t('paused')}`
: `${job.name} · ${jobDeliveryLabel(job, t)}${jobModelLabel(job) ? ` · ${jobModelLabel(job)}` : ''}`)),
h('span', { className: skin.rowActs, onClick: (e) => e.stopPropagation() },
h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) },
playIcon() || '▶'),
h('button', {
type: 'button',
className: skin.rowIcon,
title: paused ? t('resume') : t('pause'),
onClick: () => onToggle(job),
}, pauseIcon() || (paused ? '▶' : '⏸')),
h('button', { type: 'button', className: skin.rowIcon, title: t('remove'), onClick: () => onRemove(job.id) },
trashIcon() || '×'),
),
),
h('div', { className: 'dsh-ct-runs', 'data-open': open ? 'true' : 'false' },
h('div', { className: 'dsh-ct-runsInner' },
jobRuns.map((run) => {
const selectedRun = selection?.type === 'run' && selection.runId === run.id
return h('div', {
role: 'treeitem',
'aria-selected': selectedRun,
key: run.id,
className: joinClass(skin.session, selectedRun ? skin.sessionOn : ''),
'data-on': selectedRun ? 'true' : 'false',
onClick: () => onSelectRun(job.id, run),
},
h('span', { className: skin.slot }),
h('span', { className: skin.title }, (run.summary || run.status || '').split('\n')[0] || run.status),
h('span', { className: skin.time }, formatTime(run.actualAt || run.scheduledAt, job.schedule?.timezone)),
)
}),
),
),
)
}),
: (canViewAll ? renderGrouped() : visibleJobs.map((job) => renderJobGroup(job))),
),
)
}
@ -851,9 +919,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
return '__custom__'
}
function CwdField({ t, form, setForm, workspaces }) {
function CwdField({ t, form, setForm, workspaces, viewer }) {
const rows = Array.isArray(workspaces) ? workspaces : []
const mode = cwdSelectValue(form.cwd, rows)
const allowCustom = !!viewer?.canViewAll
return h('label', null, t('cwd'),
h('select', {
value: mode,
@ -869,9 +938,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
},
h('option', { value: '' }, t('cwdRecent')),
...rows.map((row) => h('option', { value: row.path, key: row.id || row.path }, `${row.title} — ${row.path}`)),
h('option', { value: '__custom__' }, t('cwdCustom')),
...(allowCustom ? [h('option', { value: '__custom__' }, t('cwdCustom'))] : []),
),
mode === '__custom__'
allowCustom && mode === '__custom__'
? h('input', {
placeholder: t('cwdPlaceholder'),
value: form.cwd,
@ -1041,7 +1110,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
h('label', null, t('scheduleTz'), h('input', { value: form.timezone, onChange: (e) => setForm({ ...form, timezone: e.target.value }) })),
h('label', null, t('timeout'), h('input', { type: 'number', min: 1, max: 240, value: form.timeoutMinutes, onChange: (e) => setForm({ ...form, timeoutMinutes: Number(e.target.value) }) })),
),
h(CwdField, { t, form, setForm, workspaces }),
h(CwdField, { t, form, setForm, workspaces, viewer }),
h(ModelField, { t, form, setForm, catalog }),
h(PresetField, { t, form, setForm, presets }),
h('label', null, t('delivery'),
@ -1072,6 +1141,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
function CronApp({ t, faces }) {
const [jobs, setJobs] = useState(() => listSnapshot.jobs)
const [viewer, setViewer] = useState(() => listSnapshot.viewer)
const [runs, setRuns] = useState(() => listSnapshot.runs)
const [selection, setSelection] = useState({ type: 'new' })
const [expanded, setExpanded] = useState({})
@ -1140,11 +1210,14 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const nextJobs = jobBody.jobs || []
const nextRuns = histBody.runs || []
const nextWorkspaces = wsBody.workspaces || []
const nextViewer = jobBody.viewer || wsBody.viewer || histBody.viewer || null
const nextCatalog = { groups: modelBody.groups || [], current: modelBody.current || null }
const nextPresets = { items: presetBody.items || [], current: presetBody.current || null }
if (jobStamp(listSnapshot.jobs) !== jobStamp(nextJobs)) {
listSnapshot.jobs = nextJobs
listSnapshot.viewer = nextViewer
setJobs(nextJobs)
setViewer(nextViewer)
}
if (runStamp(listSnapshot.runs) !== runStamp(nextRuns)) {
listSnapshot.runs = nextRuns
@ -1348,7 +1421,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
return h(React.Fragment, null,
h(JobList, {
t, jobs, runs, selection, expanded,
t, jobs, runs, selection, viewer, expanded,
onSelectJob: selectJob,
onSelectRun: selectRun,
onNew: selectNew,