设置「偏好设置」加「分析」一节(服务端 analysis 一组):判定用的阈值、Agent 每次执行最多分析几次、最多下钻几层、列出前几段,分成变化拆解、波动基线与趋势判断、差异检验、次数与层数四组。照口径账本一节的写法:草稿由偏好页持有、和其余设置共用一条保存条,存的时候整组写 analysis(没改的照原值带上,不被冲回缺省);叫法、范围照契约 LABELS / RANGES(每项下面写范围,类型、范围当场按契约的说法说,保存键不能点),两两之间的大小交给服务端核、422 的原话写在保存条上;服务端把 2 存成 2.0、写「2.0」不算改;老服务端没有这一组时不显示。check-manage 加一节:十七项的叫法都是契约的、显示生效的值、超范围和整数项、保存条的叫法、整组写、服务端拒收、改回原值、老服务端

This commit is contained in:
ThearchyHelios
2026-10-21 18:20:10 +08:00
parent 78800511e0
commit 84e6c2e13f
3 changed files with 198 additions and 7 deletions
+33 -7
View File
@@ -44,6 +44,10 @@ import {
LedgerSection, ledgerChanged, ledgerDraftOf, ledgerErrors, ledgerLabels, ledgerPayload, type LedgerDraft,
} from './settings/LedgerSettings'
import { LEDGER_UI } from '../lib/termsLedger'
import {
AnalysisSection, analysisChanged, analysisDraftOf, analysisErrors, analysisLabels, analysisPayload, type AnalysisDraft,
} from './settings/AnalysisSettings'
import { ANALYSIS_TEXT } from '../lib/termsAnalysis'
import { ACCESS_KEY_TEXT } from '../lib/termsApp'
// 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上。
@@ -697,6 +701,8 @@ function PrefsTab() {
const [verifySaves, setVerifySaves] = useState(0)
// 口径账本一组(自动按口径出具、每日上限、比对时限、升版后重算几组,pages/settings/LedgerSettings):草稿另放一份,共用保存条
const [ledgerDraft, setLedgerDraft] = useState<LedgerDraft | null>(null)
// 分析一组(判定用的阈值、次数上限,pages/settings/AnalysisSettings):草稿另放一份,共用保存条
const [analysisDraft, setAnalysisDraft] = useState<AnalysisDraft | null>(null)
const [saving, setSaving] = useState(false)
const [savedFlash, setSavedFlash] = useState(0)
const [saveError, setSaveError] = useState<unknown>(null)
@@ -791,9 +797,14 @@ function PrefsTab() {
const ledgerKeys = values?.ledger ? ledgerChanged(ledgerCur, ledgerSaved) : []
const ledgerErrs = ledgerErrors(ledgerCur, ledgerSaved)
const ledgerInvalid = Object.values(ledgerErrs).some(Boolean)
const invalid = guardInvalid || judgeInvalid || verifyInvalid || ledgerInvalid || !!appsError
const analysisSaved = analysisDraftOf(values?.analysis)
const analysisCur = analysisDraft ?? analysisSaved
const analysisKeys = values?.analysis ? analysisChanged(analysisCur, analysisSaved) : []
const analysisErrs = analysisErrors(analysisCur, analysisSaved)
const analysisInvalid = Object.values(analysisErrs).some(Boolean)
const invalid = guardInvalid || judgeInvalid || verifyInvalid || ledgerInvalid || analysisInvalid || !!appsError
const changed = (Object.keys(saved) as (keyof typeof saved)[]).filter((k) => cur[k] !== saved[k])
const dirty = values ? changed.length + verifyKeys.length + ledgerKeys.length : 0
const dirty = values ? changed.length + verifyKeys.length + ledgerKeys.length + analysisKeys.length : 0
// 外壳唯一的 blocker 在地址(pathname)变化时问:切标签、导航、⌘K、⌥ 数字、后退都算
useLeaveGuard(dirty > 0, () => confirmLeave(dirty))
@@ -818,6 +829,11 @@ function PrefsTab() {
setFieldError(null)
setLedgerDraft({ ...ledgerCur, ...patch })
}
const editAnalysis = (patch: Partial<AnalysisDraft>) => {
setSaveError(null)
setFieldError(null)
setAnalysisDraft({ ...analysisCur, ...patch })
}
/**
* 署名只在这台浏览器里,先落本机:它不该跟着后端一起「没存上」。运行默认值
@@ -853,6 +869,8 @@ function PrefsTab() {
const verify = verifyKeys.length ? verifyPayload(verifyCur) : null
// 口径账本一组同样整组写(LedgerSettings 的 ledgerPayload)
const ledger = ledgerKeys.length ? ledgerPayload(ledgerCur) : null
// 分析一组同样整组写(AnalysisSettings 的 analysisPayload)
const analysis = analysisKeys.length ? analysisPayload(analysisCur) : null
// 起草模型一组:两项一起写,空着的存 null(跟随助手的模型)
const catalogDraft = draftChanged ? { provider: cur.draftProvider || null, model: cur.draftModel.trim() || null } : null
// 应用一组同样整组写:别的键(以后加的)照原样带回去
@@ -860,7 +878,7 @@ function PrefsTab() {
? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()), user_personal_apps: cur.appsPersonal,
share_links: cur.appsShare }
: null
if (runChanged || judge || verify || ledger || catalogDraft || apps) {
if (runChanged || judge || verify || ledger || analysis || catalogDraft || apps) {
const run = {
...(values.run ?? {}),
default_memory_scope: cur.scope,
@@ -874,7 +892,7 @@ function PrefsTab() {
}
const out = await api.settings.put({
...(runChanged ? { run } : {}), ...(judge ? { judge } : {}), ...(verify ? { verify } : {}),
...(ledger ? { ledger } : {}),
...(ledger ? { ledger } : {}), ...(analysis ? { analysis } : {}),
...(catalogDraft ? { catalog_draft: catalogDraft } : {}), ...(apps ? { apps } : {}),
})
setValues((v) => ({
@@ -883,6 +901,7 @@ function PrefsTab() {
...(judge ? { judge: out?.judge ?? judge } : {}),
...(verify ? { verify: out?.verify ?? verify } : {}),
...(ledger ? { ledger: out?.ledger ?? ledger } : {}),
...(analysis ? { analysis: out?.analysis ?? analysis } : {}),
...(catalogDraft ? { catalog_draft: out?.catalog_draft ?? catalogDraft } : {}),
...(apps ? { apps: out?.apps ?? apps } : {}),
}))
@@ -893,6 +912,7 @@ function PrefsTab() {
setDraft(null)
setVerifyDraft(null)
setLedgerDraft(null)
setAnalysisDraft(null)
setSavedFlash(Date.now())
} catch (e) {
// 署名已经存上了:留在草稿里的只剩运行默认值,条上的计数跟着变少
@@ -1209,6 +1229,8 @@ function PrefsTab() {
{values.ledger && <LedgerSection cur={ledgerCur} errors={ledgerErrs} onEdit={editLedger} />}
{values.analysis && <AnalysisSection cur={analysisCur} errors={analysisErrs} onEdit={editAnalysis} />}
{values.catalog_draft && (
<DraftModelSection provider={cur.draftProvider} model={cur.draftModel} onEdit={edit}
error={fieldError?.field === 'catalog_draft.provider' ? fieldError.message : undefined} />
@@ -1273,7 +1295,7 @@ function PrefsTab() {
draftProvider: DRAFT_MODEL_SETTING_TEXT.provider, draftModel: DRAFT_MODEL_SETTING_TEXT.model,
appsDaily: APP_SETTING_TEXT.onDemandLimit, appsPersonal: APP_SETTING_TEXT.personalApps,
appsShare: APP_SETTING_TEXT.shareLinks,
})[k]).concat(verifyLabels(verifyKeys), ledgerLabels(ledgerKeys)).join('、')}
})[k]).concat(verifyLabels(verifyKeys), ledgerLabels(ledgerKeys), analysisLabels(analysisKeys)).join('、')}
</span>
</span>
{saveError != null && (
@@ -1284,13 +1306,17 @@ function PrefsTab() {
)}
<span className="flex-1" />
<button className="btn btn-sm btn-ghost" disabled={saving}
onClick={() => { setDraft(null); setVerifyDraft(null); setLedgerDraft(null); setSaveError(null); setFieldError(null) }}>放弃</button>
onClick={() => {
setDraft(null); setVerifyDraft(null); setLedgerDraft(null); setAnalysisDraft(null); setSaveError(null)
setFieldError(null)
}}>放弃</button>
<button className="btn btn-sm btn-primary" disabled={saving || invalid}
title={guardInvalid ? '护栏设置中有无效值,请修正后再保存'
: judgeInvalid ? '证据裁判的上限有无效值,请修正后再保存'
: appsError ? APP_SETTING_TEXT.onDemandLimitInvalid
: verifyInvalid ? VERIFY_SETTING_TEXT.section.saveInvalid
: ledgerInvalid ? LEDGER_UI.settings.saveInvalid : undefined}
: ledgerInvalid ? LEDGER_UI.settings.saveInvalid
: analysisInvalid ? ANALYSIS_TEXT.settings.invalid : undefined}
onClick={() => void save()}>
{saving ? <Spinner size={11} /> : <Check size={12} aria-hidden />} 保存设置
</button>
@@ -0,0 +1,84 @@
import { Field, SectionBar } from '../../components/ui'
import {
ANALYSIS_SETTING_GROUPS, ANALYSIS_SETTING_KEYS, ANALYSIS_SETTING_LABEL, ANALYSIS_TEXT, settingRangeText, settingValueProblem,
type AnalysisSettingKey,
} from '../../lib/termsAnalysis'
/*
* 设置页「偏好设置」里「分析」一节(服务端 api/settings.py 的 analysis 一组,docs/design/analysis-round/01-算子与判定.md §10):
* 判定用的阈值(「主要来源」的占比、「超出常规波动」的倍数……)、Agent 每次执行最多分析几次、最多下钻几层、列出前几段。
*
* 照 LedgerSettings.tsx 的写法:草稿由 PrefsTab 持有、和其余设置共用一条保存条(这一节只画、只报改动),存的时候整组写
* analysis——服务端对没给的项回落到缺省,只发改过的那一项会把别的项冲回缺省。字段名、范围都照服务端 analysis/contract.py
* (LABELS、RANGES,lib/termsAnalysis 一字不差地抄着,check-manage 逐项比对),422 的报错写的也是这些叫法。两两之间的大小
* (「明显超出」要大于「超出常规波动」、最多用几期不小于至少几期、「集中」不小于「主要来源」)交给服务端核,报错照原话显示。
*/
export type AnalysisDraft = Record<AnalysisSettingKey, string>
const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
/** GET /settings 的 analysis 一组(服务端给的是生效的样子,没存过的补了缺省)→ 草稿 */
export function analysisDraftOf(group: unknown): AnalysisDraft {
const g = isRecord(group) ? group : {}
return Object.fromEntries(ANALYSIS_SETTING_KEYS.map((k) => {
const v = g[k]
return [k, typeof v === 'number' && Number.isFinite(v) ? String(v) : '']
})) as AnalysisDraft
}
/** 改过的项:字不同、而且不是同一个数(服务端把 2 存成 2.0,写「2.0」不算改) */
export function analysisChanged(cur: AnalysisDraft, saved: AnalysisDraft): AnalysisSettingKey[] {
const same = (a: string, b: string) => a.trim() === b.trim()
|| (a.trim() !== '' && b.trim() !== '' && Number.isFinite(Number(a)) && Number(a) === Number(b))
return ANALYSIS_SETTING_KEYS.filter((k) => !same(cur[k], saved[k]))
}
/** 保存条上这几项的叫法:和字段名、服务端报错同一套 */
export const analysisLabels = (keys: AnalysisSettingKey[]): string[] => keys.map((k) => ANALYSIS_SETTING_LABEL[k])
/** 各项的问题(类型、范围)。只校验改过的:存着的值不合规不该挡住别的设置保存 */
export function analysisErrors(cur: AnalysisDraft, saved: AnalysisDraft): Partial<Record<AnalysisSettingKey, string>> {
const out: Partial<Record<AnalysisSettingKey, string>> = {}
for (const k of analysisChanged(cur, saved)) {
const problem = settingValueProblem(k, cur[k])
if (problem) out[k] = problem
}
return out
}
/** 草稿 → 存进去的整组 analysis */
export function analysisPayload(cur: AnalysisDraft): Record<string, number> {
return Object.fromEntries(ANALYSIS_SETTING_KEYS.map((k) => [k, Number(cur[k].trim())]))
}
const id = (k: AnalysisSettingKey) => `pref-analysis-${k.replace(/_/g, '-')}`
export function AnalysisSection({ cur, errors, onEdit }: {
cur: AnalysisDraft
errors: Partial<Record<AnalysisSettingKey, string>>
onEdit: (patch: Partial<AnalysisDraft>) => void
}) {
return (
<section data-analysis-settings="">
<SectionBar title={ANALYSIS_TEXT.settings.title} hint={ANALYSIS_TEXT.settings.hint} />
<div className="space-y-4">
{ANALYSIS_SETTING_GROUPS.map((group) => (
<div key={group.title} data-analysis-settings-group={group.title}>
<div className="mb-1.5 text-2xs font-medium text-dim">{group.title}</div>
<div className="grid grid-cols-2 gap-3 max-sm:grid-cols-1">
{group.keys.map((k) => (
<Field key={k} label={ANALYSIS_SETTING_LABEL[k]} htmlFor={id(k)} error={errors[k]} hint={settingRangeText(k)}>
{(p) => (
<input {...p} className="field tnum max-w-40" inputMode="decimal" value={cur[k]} data-analysis-setting={k}
onChange={(e) => onEdit({ [k]: e.target.value } as Partial<AnalysisDraft>)} />
)}
</Field>
))}
</div>
</div>
))}
</div>
</section>
)
}