Why: the layered permission model (project gate + per-conversation outbound/inbound flags) shipped in Backend PR #18 needs visible controls; project scope also had no settings UI yet though the API existed. Evidence: npm run build (tsc -b + vite build) and npm test (15/15) pass. Impact: new schema-driven ProjectSettingsModal (first project-scope settings surface, writes putProjectSettings, reuses SettingsModal group/input helpers); TaskSidebar gains a private shield on rows and in the row menu, per-conversation allowCrossRead in the row menu, and a project-settings gear on project group heads; Conversation header gains a cross-read toggle; contracts/api/store carry the new flags. Refs: proposals/2026-09-30-cross-session-reading.md; Backend PR #18; Dev task feature/cross-session-reading.
87 lines
3.3 KiB
TypeScript
87 lines
3.3 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'
|
||
|
||
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 [schema, setSchema] = useState<SettingDesc[]>([])
|
||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||
const [loaded, setLoaded] = useState(false)
|
||
const [saved, setSaved] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (!projectId) return
|
||
setLoaded(false)
|
||
api.getSettings(projectId).then(s => {
|
||
setSchema(s.schema)
|
||
setDraft(flatten(s.project))
|
||
setLoaded(true)
|
||
}).catch(() => undefined)
|
||
}, [projectId])
|
||
|
||
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 save = async () => {
|
||
if (!projectId) return
|
||
try {
|
||
await api.putProjectSettings(projectId, unflatten(draft))
|
||
setSaved(true)
|
||
setTimeout(() => setSaved(false), 1500)
|
||
} catch (e) {
|
||
store.toast(`项目设置保存失败:${e}`)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<SettingsShell
|
||
title={`项目设置 · ${project?.name ?? projectId}`}
|
||
active="project"
|
||
onSelect={() => undefined}
|
||
onClose={() => store.openProjectSettings(null)}
|
||
sections={[{ key: 'project', label: '本项目', icon: 'folder' }]}
|
||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
|
||
>
|
||
<div>
|
||
{!loaded ? (
|
||
<EmptyState icon="settings">加载中…</EmptyState>
|
||
) : (
|
||
<>
|
||
<div className="empty-note" style={{ marginBottom: 10 }}>
|
||
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
|
||
</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" onClick={save}>保存项目设置</Btn>
|
||
</>
|
||
)}
|
||
</div>
|
||
</SettingsShell>
|
||
)
|
||
}
|