Why: S9 acceptance finding 3 — the project settings modal exposed the host override settings (including the model/key group, masked) to plain project members; the duties data plane was already permission-enforced, but the host settings surface had no gate. Evidence: vitest 34 files / 293 tests pass with the change; tsc reports no errors in the modified file. Ruling per the acceptance plan P1-B: the modal entry stays (granted duty managers need the RDM tabs), while the host override settings group renders only for the project owner or a system admin; RDM tabs keep their own fail-closed gating. Impact: ProjectSettingsModal project tab renders an explanatory empty state for non-privileged viewers; members/duties/matrix tabs unchanged. Refs: S9 acceptance report P1-B
121 lines
6.0 KiB
TypeScript
121 lines
6.0 KiB
TypeScript
// 项目设置面板(提案 2026-09-30-cross-session-reading):首个项目级设置 UI。
|
||
// schema 驱动渲染 project scope(4.9 覆盖链:system → user → project),
|
||
// 写 putProjectSettings;后续项目级设置项经 settings schema 自动出现,零开发。
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { api } from '../api'
|
||
import { store, useStore } from '../store'
|
||
import type { SettingDesc } from '../contracts'
|
||
import { SettingsShell, Btn, EmptyState } from '@ui'
|
||
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
|
||
import { RdmMembersSection, RdmDutiesSection, RdmPermMatrixSection } from './adminRdmSections'
|
||
import { rdmProjectReadError } from '../rdmManagement'
|
||
import { projectActionError, projectWorkError } from '../projectmeta'
|
||
|
||
export function ProjectSettingsModal() {
|
||
const projectId = useStore(s => s.ui.projectSettingsId)
|
||
const projects = useStore(s => s.projects)
|
||
const project = projects.find(p => p.id === projectId)
|
||
const rdmMembersAvailable = useStore(s => s.rdmMembersAvailable)
|
||
const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable)
|
||
const rdmPermMatrixAvailable = useStore(s => s.rdmPermMatrixAvailable)
|
||
const owner = useStore(s => s.me?.owner)
|
||
const isAdmin = useStore(s => s.me?.isAdmin ?? false)
|
||
// P1-B 裁定:宿主覆盖设置(含模型与密钥)仅项目负责人/系统管理员可查看与修改;
|
||
// RDM 页签(成员/文档职责/项目权限)不在此列——数据面由 RDM 权限自行收敛,
|
||
// 授权的职责管理者仍可进入。
|
||
const projectPrivileged = !!project && (project.owner === owner || isAdmin)
|
||
const [tab, setTab] = useState('project')
|
||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||
const [loaded, setLoaded] = useState(false)
|
||
const [saved, setSaved] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
useEffect(() => {
|
||
if (!projectId) return
|
||
let active = true
|
||
setLoaded(false); setSaved(false); setError(''); setDraft({}); setSchema([])
|
||
api.getSettings(projectId).then(s => {
|
||
if (!active) return
|
||
setSchema(s.schema)
|
||
setDraft(flatten(s.project))
|
||
setLoaded(true)
|
||
}).catch(() => { if (active) setError('项目设置未能加载,请关闭后重新打开或联系项目负责人') })
|
||
return () => { active = false }
|
||
}, [projectId, owner])
|
||
|
||
const groups = useMemo(() => {
|
||
const g: Record<string, SettingDesc[]> = {}
|
||
for (const d of schema) (g[d.group] ??= []).push(d)
|
||
return g
|
||
}, [schema])
|
||
|
||
if (!projectId) return null
|
||
const rdmError = rdmProjectReadError(project)
|
||
const writeError = projectWorkError(project)
|
||
const rdmTab = tab === 'members' || tab === 'duties' || tab === 'matrix'
|
||
|
||
const save = async () => {
|
||
if (!projectId) return
|
||
if (writeError) { store.toast(writeError); return }
|
||
try {
|
||
await api.putProjectSettings(projectId, unflatten(draft))
|
||
setSaved(true)
|
||
setTimeout(() => setSaved(false), 1500)
|
||
} catch (e) {
|
||
store.toast(`项目设置保存失败:${projectActionError(e)}`)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<SettingsShell
|
||
title={`项目设置 · ${project?.name ?? projectId}`}
|
||
active={tab}
|
||
onSelect={setTab}
|
||
onClose={() => store.openProjectSettings(null)}
|
||
sections={[
|
||
{ key: 'project', label: '本项目', icon: 'folder' },
|
||
// U6/KTD5:成员与文档职责页签按 RDM 命令面门控(普通基座不出现)。
|
||
...(rdmMembersAvailable ? [{ key: 'members', label: '成员', icon: 'users' as const }] : []),
|
||
...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责', icon: 'shield' as const }] : []),
|
||
...(rdmPermMatrixAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
|
||
]}
|
||
footer={saved ? <span style={{ color: 'var(--ok-text)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
|
||
>
|
||
<div>
|
||
{rdmTab && rdmError ? <EmptyState icon="folder">{rdmError}</EmptyState> : project && <>
|
||
{tab === 'members' && <RdmMembersSection key={project.id} project={project} />}
|
||
{tab === 'duties' && <RdmDutiesSection key={project.id} project={project} />}
|
||
{tab === 'matrix' && <RdmPermMatrixSection key={project.id} project={project} />}
|
||
</>}
|
||
{tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !loaded ? (
|
||
<EmptyState icon="settings">{error || '加载中…'}</EmptyState>
|
||
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !projectPrivileged ? (
|
||
<EmptyState icon="settings">项目覆盖设置(含模型与密钥)仅项目负责人或系统管理员可查看与修改。</EmptyState>
|
||
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? (
|
||
<>
|
||
<div className="empty-note" style={{ marginBottom: 10 }}>
|
||
{writeError ?? '此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。'}
|
||
</div>
|
||
{Object.entries(groups).map(([g, descs]) => (
|
||
<div className="settings-group" key={g}>
|
||
<h4>{groupNames[g] ?? g}</h4>
|
||
{descs.map(d => (
|
||
<div className="setting-row" key={d.key}>
|
||
<label title={d.key}>{d.label}</label>
|
||
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
|
||
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
|
||
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
<Btn variant="primary" icon="check" disabled={!!writeError} onClick={save}>保存项目设置</Btn>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</SettingsShell>
|
||
)
|
||
}
|