Simplify settings copy and polish responsive form layout

This commit is contained in:
oliver 2026-10-11 13:02:39 +08:00
parent 0b9a3a82bc
commit 9271494b37
8 changed files with 590 additions and 690 deletions

View file

@ -78,6 +78,10 @@
## 验证与维护 ## 验证与维护
设置页已精简重复的“自动保存”“即时挂载/卸载”和“已覆盖”提示。发布范围使用简短标签,挂载解释与明细集中在折叠的诊断信息中;模型语言独立分组,使用本地化选项。连接字段在宽面板并排、窄面板单列,保存/丢弃栏仅在待处理修改、保存中或保存失败时出现。实际错误仍显示在对应区域。
本次界面验证覆盖运行中的桌面深色设置页、390px 窄屏布局、中英文文案、预设列表展开与选择,以及手工草稿保存后收起操作栏。预览凭据服务为内存模拟,不读取或写入真实 token。预览截图已更新为精简后的界面。
验证命令: 验证命令:
```powershell ```powershell

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Before After
Before After

View file

@ -23,7 +23,15 @@ const scope = {
async set(field: string, value: unknown) { user[field] = value; for (const fn of listeners) fn() }, async set(field: string, value: unknown) { user[field] = value; for (const fn of listeners) fn() },
async unset(field: string) { delete user[field]; for (const fn of listeners) fn() }, async unset(field: string) { delete user[field]; for (const fn of listeners) fn() },
} }
const controller = new NetxopsCardController(scope, { get: () => undefined } as never) const previewCredentials = {
async describe(refs: string[]) {
return { ok: true, value: Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])) }
},
async set() { /* Preview only; no credentials are stored. */ },
}
const controller = new NetxopsCardController(scope, {
get: key => key === 'remote.credentials' ? previewCredentials : undefined,
} as never)
controller.setAlarmPushRpc(async (_channel, endpoint) => { controller.setAlarmPushRpc(async (_channel, endpoint) => {
const value = scope.getSnapshot().value const value = scope.getSnapshot().value
if (endpoint === 'presets.catalog') return { ok: true, value: { presets: [ if (endpoint === 'presets.catalog') return { ok: true, value: { presets: [

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,6 @@
/** /** Netx Ops settings: connection, publication, knowledge and delivery. */
* Netx Ops settings section — uds-auth visual language (page header + cards).
*/
import { useEffect, useRef, useState, type ReactNode } from 'react' import type { ReactNode } from 'react'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { NetxopsCardFace, NetxopsCardState } from './controller.ts' import type { NetxopsCardFace, NetxopsCardState } from './controller.ts'
import type { NetxopsLocaleKey } from './locales.ts' import type { NetxopsLocaleKey } from './locales.ts'
@ -66,7 +64,7 @@ function kbBadgeLabel(
return t('kbStatusUnconfigured') return t('kbStatusUnconfigured')
} }
if (snapshot.status === 'error') { if (snapshot.status === 'error') {
return fillTemplate(t('kbStatusError'), { detail: snapshot.errorMessage || 'error' }) return t('kbStatusError')
} }
return fillTemplate(t('kbStatusConfigured'), { return fillTemplate(t('kbStatusConfigured'), {
operator: snapshot.operatorName, operator: snapshot.operatorName,
@ -88,12 +86,11 @@ function toolsBadgeLabel(
if (!view) return t('toolsStatusUnknown') if (!view) return t('toolsStatusUnknown')
if (!view.toolsEnabled) return t('toolsDisabled') if (!view.toolsEnabled) return t('toolsDisabled')
if (view.error) { if (view.error) {
return fillTemplate(t('toolsStatusError'), { detail: view.error }) return t('toolsStatusError')
} }
if (view.count > 0) { if (view.count > 0) {
return fillTemplate(t('toolsStatusMounted'), { return fillTemplate(t('toolsStatusMounted'), {
count: String(view.count), count: String(view.count),
version: view.version || '?',
}) })
} }
return t('toolsStatusEmpty') return t('toolsStatusEmpty')
@ -136,9 +133,8 @@ export type NetxopsCardProps =
function ValueField(props: { function ValueField(props: {
id: string id: string
label: string label: string
hint: string hint?: string
field: CardFieldState field: CardFieldState
overriddenLabel: string
resetLabel: string resetLabel: string
invalidLabel: string invalidLabel: string
disabled: boolean disabled: boolean
@ -152,7 +148,6 @@ function ValueField(props: {
{props.field.overridden {props.field.overridden
? ( ? (
<span className="dsh-nx-badges"> <span className="dsh-nx-badges">
<span className="dsh-nx-badge">{props.overriddenLabel}</span>
<button type="button" className="dsh-nx-reset" disabled={props.disabled} onClick={props.onReset}> <button type="button" className="dsh-nx-reset" disabled={props.disabled} onClick={props.onReset}>
{props.resetLabel} {props.resetLabel}
</button> </button>
@ -164,14 +159,13 @@ function ValueField(props: {
id={props.id} id={props.id}
className={props.field.invalid ? 'dsh-nx-inputInvalid' : undefined} className={props.field.invalid ? 'dsh-nx-inputInvalid' : undefined}
type="text" type="text"
placeholder={props.hint}
value={props.field.text} value={props.field.text}
disabled={props.disabled} disabled={props.disabled}
aria-invalid={props.field.invalid || undefined} aria-invalid={props.field.invalid || undefined}
onChange={(event) => { props.onEdit(event.target.value) }} onChange={(event) => { props.onEdit(event.target.value) }}
/> />
<p className={props.field.invalid ? 'dsh-nx-invalid' : 'dsh-nx-hint'}> {props.field.invalid ? <p className="dsh-nx-invalid">{props.invalidLabel}</p> : null}
{props.field.invalid ? props.invalidLabel : props.hint}
</p>
</div> </div>
) )
} }
@ -179,10 +173,8 @@ function ValueField(props: {
function SelectField(props: { function SelectField(props: {
id: string id: string
label: string label: string
hint: string
field: CardFieldState field: CardFieldState
options: ReadonlyArray<{ value: string, label: string }> options: ReadonlyArray<{ value: string, label: string }>
overriddenLabel: string
resetLabel: string resetLabel: string
invalidLabel: string invalidLabel: string
disabled: boolean disabled: boolean
@ -197,7 +189,6 @@ function SelectField(props: {
{props.field.overridden {props.field.overridden
? ( ? (
<span className="dsh-nx-badges"> <span className="dsh-nx-badges">
<span className="dsh-nx-badge">{props.overriddenLabel}</span>
<button type="button" className="dsh-nx-reset" disabled={props.disabled} onClick={props.onReset}> <button type="button" className="dsh-nx-reset" disabled={props.disabled} onClick={props.onReset}>
{props.resetLabel} {props.resetLabel}
</button> </button>
@ -220,9 +211,7 @@ function SelectField(props: {
<option key={row.value} value={row.value}>{row.label}</option> <option key={row.value} value={row.value}>{row.label}</option>
))} ))}
</select> </select>
<p className={props.field.invalid ? 'dsh-nx-invalid' : 'dsh-nx-hint'}> {props.field.invalid ? <p className="dsh-nx-invalid">{props.invalidLabel}</p> : null}
{props.field.invalid ? props.invalidLabel : props.hint}
</p>
</div> </div>
) )
} }
@ -343,7 +332,6 @@ function PresetTargets(props: {
return ( return (
<details className="dsh-nx-targets"> <details className="dsh-nx-targets">
<summary>{props.t('selectedPresets')} · {selected.length}</summary> <summary>{props.t('selectedPresets')} · {selected.length}</summary>
<p className="dsh-nx-hint">{props.t('publicationHint')}</p>
{props.state.presetCatalogError ? <p className="dsh-nx-invalid">{props.t('presetCatalogUnavailable')}</p> : null} {props.state.presetCatalogError ? <p className="dsh-nx-invalid">{props.t('presetCatalogUnavailable')}</p> : null}
{[...rows, ...unknown.map(id => ({ id, name: undefined, broken: undefined }))].map(row => ( {[...rows, ...unknown.map(id => ({ id, name: undefined, broken: undefined }))].map(row => (
<label key={row.id} className="dsh-nx-checkRow"> <label key={row.id} className="dsh-nx-checkRow">
@ -361,6 +349,7 @@ function PresetTargets(props: {
function CapabilityGroupBlock(props: { function CapabilityGroupBlock(props: {
title: string title: string
toolCount?: number
inPresetLabel: string inPresetLabel: string
publicLabel: string publicLabel: string
inPreset: CardFieldState inPreset: CardFieldState
@ -371,8 +360,11 @@ function CapabilityGroupBlock(props: {
targets?: ReactNode targets?: ReactNode
}) { }) {
return ( return (
<div className="dsh-nx-groupBlock"> <div className="dsh-nx-groupBlock" role="group" aria-label={props.title}>
<div className="dsh-nx-groupTitle">{props.title}</div> <div className="dsh-nx-groupTitle">
<span>{props.title}</span>
{props.toolCount !== undefined ? <span className="dsh-nx-badgeMuted">{props.toolCount}</span> : null}
</div>
<div className="dsh-nx-groupChecks"> <div className="dsh-nx-groupChecks">
<label className="dsh-nx-checkRow"> <label className="dsh-nx-checkRow">
<input <input
@ -404,33 +396,26 @@ function ToggleField(props: {
checked: boolean checked: boolean
disabled: boolean disabled: boolean
overridden: boolean overridden: boolean
overriddenLabel: string
resetLabel: string resetLabel: string
onToggle: (checked: boolean) => void onToggle: (checked: boolean) => void
onReset: () => void onReset: () => void
trailing?: ReactNode trailing?: ReactNode
hint?: ReactNode
error?: ReactNode error?: ReactNode
}) { }) {
return ( return (
<div className="dsh-nx-settings-field"> <div className="dsh-nx-settings-field dsh-nx-toggleField">
<div className="dsh-nx-field-head"> <div className="dsh-nx-field-head">
<label htmlFor={props.id}>{props.label}</label> <label htmlFor={props.id}>{props.label}</label>
<span className="dsh-nx-badges"> <span className="dsh-nx-badges">
{props.trailing} {props.trailing}
{props.overridden {props.overridden
? ( ? (
<> <button type="button" className="dsh-nx-reset" disabled={props.disabled} onClick={props.onReset}>
<span className="dsh-nx-badge">{props.overriddenLabel}</span> {props.resetLabel}
<button type="button" className="dsh-nx-reset" disabled={props.disabled} onClick={props.onReset}> </button>
{props.resetLabel}
</button>
</>
) )
: null} : null}
</span> </span>
</div>
<label className="dsh-nx-checkRow" htmlFor={props.id}>
<input <input
id={props.id} id={props.id}
type="checkbox" type="checkbox"
@ -439,8 +424,7 @@ function ToggleField(props: {
aria-label={props.label} aria-label={props.label}
onChange={(event) => { props.onToggle(event.target.checked) }} onChange={(event) => { props.onToggle(event.target.checked) }}
/> />
</label> </div>
{props.hint}
{props.error} {props.error}
</div> </div>
) )
@ -450,23 +434,6 @@ export function NetxopsCard(props: NetxopsCardProps) {
ensureStyles() ensureStyles()
const { t } = props const { t } = props
const state: NetxopsCardState = props.useNetxopsCard(snapshot => snapshot) const state: NetxopsCardState = props.useNetxopsCard(snapshot => snapshot)
const [flash, setFlash] = useState<'ok' | 'err' | null>(null)
const saveStarted = useRef(false)
useEffect(() => {
if (state.saving) {
saveStarted.current = true
setFlash(null)
return
}
if (!saveStarted.current) return
saveStarted.current = false
if (state.failed) {
setFlash('err')
return
}
if (!state.dirty) setFlash('ok')
}, [state.dirty, state.failed, state.saving])
if (!state.available) { if (!state.available) {
return ( return (
@ -490,7 +457,6 @@ export function NetxopsCard(props: NetxopsCardProps) {
<section className="dsh-nx-settings" aria-label={t('title')}> <section className="dsh-nx-settings" aria-label={t('title')}>
<header> <header>
<h2>{t('title')}</h2> <h2>{t('title')}</h2>
<p className="dsh-nx-settings-intro">{t('description')}</p>
</header> </header>
{!state.writable {!state.writable
@ -499,25 +465,6 @@ export function NetxopsCard(props: NetxopsCardProps) {
<div className="dsh-nx-settings-card"> <div className="dsh-nx-settings-card">
<h3>{t('sectionConnection')}</h3> <h3>{t('sectionConnection')}</h3>
<div className="dsh-nx-settings-field">
<div className="dsh-nx-field-head">
<span>{t('toolsStatus')}</span>
<StatusBadge
tone={toolsMountTone(state.toolsStatus)}
label={toolsBadgeLabel(t, state.toolsStatus)}
/>
</div>
<p className="dsh-nx-hint">{t('mountStatusHint')}</p>
{state.toolsStatus && state.toolsStatus.mounts.length > 0 ? (
<details><summary>{t('mountDetails')}</summary>
{state.toolsStatus.mounts.map((mount, index) => (
<p key={`${mount.source}:${index}`} className={mount.error ? 'dsh-nx-invalid' : 'dsh-nx-hint'}>
{mount.source} · {mount.count} · {mount.groups.join(', ') || '—'}{mount.error ? ` · ${mount.error}` : ''}
</p>
))}
</details>
) : null}
</div>
<div className="dsh-nx-settings-field"> <div className="dsh-nx-settings-field">
<div className="dsh-nx-field-head"> <div className="dsh-nx-field-head">
<label htmlFor="netxops-api-token">{t('apiToken')}</label> <label htmlFor="netxops-api-token">{t('apiToken')}</label>
@ -531,96 +478,106 @@ export function NetxopsCard(props: NetxopsCardProps) {
id="netxops-api-token" id="netxops-api-token"
type="password" type="password"
autoComplete="off" autoComplete="off"
placeholder={t('apiTokenHint')}
value={state.apiToken.text} value={state.apiToken.text}
disabled={!state.apiTokenWritable} disabled={!state.apiTokenWritable}
onChange={(event) => { props.edit('apiToken', event.target.value) }} onChange={(event) => { props.edit('apiToken', event.target.value) }}
/> />
<p className="dsh-nx-hint"> {!state.apiTokenRemoteReady ? <p className="dsh-nx-invalid">{t('apiTokenUnavailable')}</p> : null}
{state.apiTokenRemoteReady ? t('apiTokenHint') : t('apiTokenUnavailable')}
</p>
</div> </div>
<ValueField <ValueField
id="netxops-api-url" id="netxops-api-url"
label={t('apiUrl')} label={t('apiUrl')}
hint={t('apiUrlHint')} hint={t('apiUrlHint')}
field={state.apiUrl} field={state.apiUrl}
overriddenLabel={t('overridden')}
resetLabel={t('reset')} resetLabel={t('reset')}
invalidLabel={t('apiUrlInvalid')} invalidLabel={t('apiUrlInvalid')}
disabled={disabled} disabled={disabled}
onEdit={(text) => { props.edit('apiUrl', text) }} onEdit={(text) => { props.edit('apiUrl', text) }}
onReset={() => { props.resetField('apiUrl') }} onReset={() => { props.resetField('apiUrl') }}
/> />
<ValueField <div className="dsh-nx-fieldGrid">
id="netxops-lang" <SelectField
label={t('lang')} id="netxops-lang"
hint={t('langHint')} label={t('lang')}
field={state.lang} field={state.lang}
overriddenLabel={t('overridden')} options={[
resetLabel={t('reset')} { value: 'zh', label: t('languageChinese') },
invalidLabel={t('invalid')} { value: 'en', label: t('languageEnglish') },
disabled={disabled} ]}
onEdit={(text) => { props.edit('lang', text) }} resetLabel={t('reset')}
onReset={() => { props.resetField('lang') }} invalidLabel={t('invalid')}
/> disabled={disabled}
<SelectField onEdit={(text) => { props.edit('lang', text) }}
id="netxops-thinking-language" onReset={() => { props.resetField('lang') }}
label={t('thinkingLanguage')} />
hint={t('thinkingLanguageHint')} <SelectField
field={state.thinkingLanguage} id="netxops-nms-provider"
options={[ label={t('nmsProvider')}
{ value: 'auto', label: 'auto' }, field={state.nmsProvider}
{ value: 'zh-CN', label: 'zh-CN' }, options={[{ value: 'zte-ume', label: 'ZTE UME' }]}
{ value: 'en', label: 'en' }, resetLabel={t('reset')}
]} invalidLabel={t('invalid')}
overriddenLabel={t('overridden')} disabled={disabled}
resetLabel={t('reset')} onEdit={(text) => { props.edit('nmsProvider', text) }}
invalidLabel={t('invalid')} onReset={() => { props.resetField('nmsProvider') }}
disabled={disabled} />
onEdit={(text) => { props.edit('thinkingLanguage', text) }} </div>
onReset={() => { props.resetField('thinkingLanguage') }}
/>
<SelectField
id="netxops-reply-language"
label={t('replyLanguage')}
hint={t('replyLanguageHint')}
field={state.replyLanguage}
options={[
{ value: 'follow-user', label: 'follow-user' },
{ value: 'zh', label: 'zh' },
{ value: 'en', label: 'en' },
]}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalid')}
disabled={disabled}
onEdit={(text) => { props.edit('replyLanguage', text) }}
onReset={() => { props.resetField('replyLanguage') }}
/>
<ValueField
id="netxops-nms-provider"
label={t('nmsProvider')}
hint={t('nmsProviderHint')}
field={state.nmsProvider}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalid')}
disabled={disabled}
onEdit={(text) => { props.edit('nmsProvider', text) }}
onReset={() => { props.resetField('nmsProvider') }}
/>
</div> </div>
<div className="dsh-nx-settings-card"> <div className="dsh-nx-settings-card">
<h3>{t('sectionCapabilities')}</h3> <div className="dsh-nx-card-head">
<h3>{t('sectionLanguage')}</h3>
<span className="dsh-nx-hint">{t('languageScope')}</span>
</div>
<div className="dsh-nx-fieldGrid">
<SelectField
id="netxops-thinking-language"
label={t('thinkingLanguage')}
field={state.thinkingLanguage}
options={[
{ value: 'auto', label: t('languageAuto') },
{ value: 'zh-CN', label: t('languageChinese') },
{ value: 'en', label: t('languageEnglish') },
]}
resetLabel={t('reset')}
invalidLabel={t('invalid')}
disabled={disabled}
onEdit={(text) => { props.edit('thinkingLanguage', text) }}
onReset={() => { props.resetField('thinkingLanguage') }}
/>
<SelectField
id="netxops-reply-language"
label={t('replyLanguage')}
field={state.replyLanguage}
options={[
{ value: 'follow-user', label: t('languageFollowUser') },
{ value: 'zh', label: t('languageChinese') },
{ value: 'en', label: t('languageEnglish') },
]}
resetLabel={t('reset')}
invalidLabel={t('invalid')}
disabled={disabled}
onEdit={(text) => { props.edit('replyLanguage', text) }}
onReset={() => { props.resetField('replyLanguage') }}
/>
</div>
</div>
<div className="dsh-nx-settings-card">
<div className="dsh-nx-card-head">
<h3>{t('sectionCapabilities')}</h3>
<StatusBadge tone={toolsMountTone(state.toolsStatus)} label={toolsBadgeLabel(t, state.toolsStatus)} />
</div>
<ToggleField id="netxops-tools-enabled" label={t('toolsEnabled')} <ToggleField id="netxops-tools-enabled" label={t('toolsEnabled')}
checked={state.toolsEnabled.text === 'true'} disabled={disabled} checked={state.toolsEnabled.text === 'true'} disabled={disabled}
overridden={state.toolsEnabled.overridden} overriddenLabel={t('overridden')} resetLabel={t('reset')} overridden={state.toolsEnabled.overridden} resetLabel={t('reset')}
onToggle={checked => { props.edit('toolsEnabled', String(checked)) }} onToggle={checked => { props.edit('toolsEnabled', String(checked)) }}
onReset={() => { props.resetField('toolsEnabled') }} onReset={() => { props.resetField('toolsEnabled') }}
hint={<p className="dsh-nx-hint">{t('toolsEnabledHint')}</p>} /> error={state.toolsStatus?.error ? <p className="dsh-nx-invalid" role="status">{state.toolsStatus.error}</p> : null} />
<CapabilityGroupBlock <CapabilityGroupBlock
title={`${t('groupOps')} · ${TOOLS_BY_GROUP.ops.length}`} title={t('groupOps')}
toolCount={TOOLS_BY_GROUP.ops.length}
targets={targets('groupOpsPresets', state.groupOpsPresets)} targets={targets('groupOpsPresets', state.groupOpsPresets)}
inPresetLabel={t('groupInPreset')} inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')} publicLabel={t('groupPublic')}
@ -635,7 +592,8 @@ export function NetxopsCard(props: NetxopsCardProps) {
}} }}
/> />
<CapabilityGroupBlock <CapabilityGroupBlock
title={`${t('groupTopology')} · ${TOOLS_BY_GROUP.topology.length}`} title={t('groupTopology')}
toolCount={TOOLS_BY_GROUP.topology.length}
targets={targets('groupTopologyPresets', state.groupTopologyPresets)} targets={targets('groupTopologyPresets', state.groupTopologyPresets)}
inPresetLabel={t('groupInPreset')} inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')} publicLabel={t('groupPublic')}
@ -650,7 +608,8 @@ export function NetxopsCard(props: NetxopsCardProps) {
}} }}
/> />
<CapabilityGroupBlock <CapabilityGroupBlock
title={`${t('groupBizMonitor')} · ${TOOLS_BY_GROUP.bizMonitor.length}`} title={t('groupBizMonitor')}
toolCount={TOOLS_BY_GROUP.bizMonitor.length}
targets={targets('groupBizMonitorPresets', state.groupBizMonitorPresets)} targets={targets('groupBizMonitorPresets', state.groupBizMonitorPresets)}
inPresetLabel={t('groupInPreset')} inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')} publicLabel={t('groupPublic')}
@ -664,22 +623,30 @@ export function NetxopsCard(props: NetxopsCardProps) {
props.edit('groupBizMonitorPublic', checked ? 'true' : 'false') props.edit('groupBizMonitorPublic', checked ? 'true' : 'false')
}} }}
/> />
{state.toolsStatus && state.toolsStatus.mounts.length > 0 ? (
<details className="dsh-nx-diagnostics"><summary>{t('mountDetails')}</summary>
<p className="dsh-nx-hint">{t('mountStatusHint')}</p>
{state.toolsStatus.mounts.map((mount, index) => (
<p key={`${mount.source}:${index}`} className={mount.error ? 'dsh-nx-invalid' : 'dsh-nx-hint'}>
{mount.source} · {mount.count} · {mount.groups.join(', ') || '—'}{mount.error ? ` · ${mount.error}` : ''}
</p>
))}
</details>
) : null}
</div> </div>
<div className="dsh-nx-settings-card"> <div className="dsh-nx-settings-card">
<h3>{t('sectionKnowledge')}</h3> <div className="dsh-nx-card-head">
<h3>{t('sectionKnowledge')}</h3>
<StatusBadge tone={kbStatusTone(state.kbStatus?.status ?? 'unconfigured')} label={kbBadgeLabel(t, state.kbStatus)} />
</div>
<div className="dsh-nx-settings-field"> <div className="dsh-nx-settings-field">
<div className="dsh-nx-field-head"> <div className="dsh-nx-field-head">
<label htmlFor="netxops-kb-root">{t('kbRoot')}</label> <label htmlFor="netxops-kb-root">{t('kbRoot')}</label>
<span className="dsh-nx-badges"> <span className="dsh-nx-badges">
<StatusBadge
tone={kbStatusTone(state.kbStatus?.status ?? 'unconfigured')}
label={kbBadgeLabel(t, state.kbStatus)}
/>
{state.kbRoot.overridden {state.kbRoot.overridden
? ( ? (
<> <>
<span className="dsh-nx-badge">{t('overridden')}</span>
<button <button
type="button" type="button"
className="dsh-nx-reset" className="dsh-nx-reset"
@ -698,6 +665,7 @@ export function NetxopsCard(props: NetxopsCardProps) {
id="netxops-kb-root" id="netxops-kb-root"
className={state.kbRoot.invalid ? 'dsh-nx-inputInvalid' : undefined} className={state.kbRoot.invalid ? 'dsh-nx-inputInvalid' : undefined}
type="text" type="text"
placeholder={t('kbRootHint')}
value={state.kbRoot.text} value={state.kbRoot.text}
disabled={disabled} disabled={disabled}
aria-invalid={state.kbRoot.invalid || undefined} aria-invalid={state.kbRoot.invalid || undefined}
@ -706,20 +674,14 @@ export function NetxopsCard(props: NetxopsCardProps) {
<button <button
type="button" type="button"
className="dsh-nx-btn" className="dsh-nx-btn"
disabled={disabled} disabled={disabled || !state.kbDirectoryPickerReady}
title={!state.kbDirectoryPickerReady ? t('kbBrowseUnavailable') : undefined}
onClick={() => { props.browseKbRoot() }} onClick={() => { props.browseKbRoot() }}
> >
{t('kbBrowse')} {t('kbBrowse')}
</button> </button>
</div> </div>
<p className={state.kbRoot.invalid ? 'dsh-nx-invalid' : 'dsh-nx-hint'}> {state.kbRoot.invalid ? <p className="dsh-nx-invalid">{t('invalid')}</p> : null}
{state.kbRoot.invalid
? t('invalid')
: state.kbDirectoryPickerReady
? t('kbRootHint')
: t('kbBrowseUnavailable')}
</p>
<p className="dsh-nx-hint">{t('kbSaveHint')}</p>
{state.kbUiError {state.kbUiError
? ( ? (
<p className="dsh-nx-invalid" role="status"> <p className="dsh-nx-invalid" role="status">
@ -730,7 +692,7 @@ export function NetxopsCard(props: NetxopsCardProps) {
{state.kbStatus?.status === 'error' && state.kbStatus.errorMessage {state.kbStatus?.status === 'error' && state.kbStatus.errorMessage
? <p className="dsh-nx-invalid" role="status">{state.kbStatus.errorMessage}</p> ? <p className="dsh-nx-invalid" role="status">{state.kbStatus.errorMessage}</p>
: null} : null}
{state.kbStatus?.status === 'configured' && state.kbStatus.realRoot {state.kbStatus?.status === 'configured' && state.kbStatus.realRoot && state.kbStatus.realRoot !== state.kbRoot.text
? <p className="dsh-nx-hint">{state.kbStatus.realRoot}</p> ? <p className="dsh-nx-hint">{state.kbStatus.realRoot}</p>
: null} : null}
</div> </div>
@ -759,7 +721,6 @@ export function NetxopsCard(props: NetxopsCardProps) {
checked={state.alarmPushEnabled.text === 'true'} checked={state.alarmPushEnabled.text === 'true'}
disabled={disabled} disabled={disabled}
overridden={state.alarmPushEnabled.overridden} overridden={state.alarmPushEnabled.overridden}
overriddenLabel={t('overridden')}
resetLabel={t('reset')} resetLabel={t('reset')}
onToggle={(checked) => { onToggle={(checked) => {
props.edit('alarmPushEnabled', checked ? 'true' : 'false') props.edit('alarmPushEnabled', checked ? 'true' : 'false')
@ -769,11 +730,10 @@ export function NetxopsCard(props: NetxopsCardProps) {
? ( ? (
<StatusBadge <StatusBadge
phase={pushStatus.phase} phase={pushStatus.phase}
label={`${t('alarmPushStatus')}: ${t(phaseLocaleKey(pushStatus.phase))}`} label={t(phaseLocaleKey(pushStatus.phase))}
/> />
) )
: null} : null}
hint={pushStatus?.wsUrl ? <p className="dsh-nx-hint">{pushStatus.wsUrl}</p> : null}
error={pushStatus?.lastError error={pushStatus?.lastError
? <p className="dsh-nx-invalid" role="status">{pushStatus.lastError}</p> ? <p className="dsh-nx-invalid" role="status">{pushStatus.lastError}</p>
: null} : null}
@ -784,7 +744,6 @@ export function NetxopsCard(props: NetxopsCardProps) {
checked={state.alarmDeliverDsh.text === 'true'} checked={state.alarmDeliverDsh.text === 'true'}
disabled={disabled} disabled={disabled}
overridden={state.alarmDeliverDsh.overridden} overridden={state.alarmDeliverDsh.overridden}
overriddenLabel={t('overridden')}
resetLabel={t('reset')} resetLabel={t('reset')}
onToggle={(checked) => { onToggle={(checked) => {
props.edit('alarmDeliverDsh', checked ? 'true' : 'false') props.edit('alarmDeliverDsh', checked ? 'true' : 'false')
@ -816,7 +775,6 @@ export function NetxopsCard(props: NetxopsCardProps) {
<div className="dsh-nx-settings-card"> <div className="dsh-nx-settings-card">
<h3>{t('sectionExport')}</h3> <h3>{t('sectionExport')}</h3>
<div className="dsh-nx-settings-field"> <div className="dsh-nx-settings-field">
<span className="dsh-nx-field-label">{t('sessionsExport')}</span>
{state.sessionsExportStatus === null {state.sessionsExportStatus === null
? <p className="dsh-nx-hint">{t('sessionsExportUnavailable')}</p> ? <p className="dsh-nx-hint">{t('sessionsExportUnavailable')}</p>
: state.sessionsExportStatus.available : state.sessionsExportStatus.available
@ -863,7 +821,7 @@ export function NetxopsCard(props: NetxopsCardProps) {
</div> </div>
</div> </div>
<div className="dsh-nx-settings-actions"> {state.dirty || state.saving || state.failed ? <div className="dsh-nx-settings-actions">
<button <button
type="button" type="button"
className="dsh-nx-btn dsh-nx-btn-primary" className="dsh-nx-btn dsh-nx-btn-primary"
@ -876,23 +834,17 @@ export function NetxopsCard(props: NetxopsCardProps) {
type="button" type="button"
className="dsh-nx-btn dsh-nx-btn-ghost" className="dsh-nx-btn dsh-nx-btn-ghost"
disabled={!state.dirty || state.saving} disabled={!state.dirty || state.saving}
onClick={() => { onClick={props.discard}
props.discard()
setFlash(null)
}}
> >
{t('discard')} {t('discard')}
</button> </button>
{state.dirty && !state.saving {state.dirty && !state.saving
? <span className="dsh-nx-settings-msg">{t('unsaved')}</span> ? <span className="dsh-nx-settings-msg">{t('unsaved')}</span>
: null} : null}
{flash === 'ok' && !state.dirty {state.failed
? <span className="dsh-nx-settings-msg ok">{t('configSaved')}</span>
: null}
{(flash === 'err' || state.failed)
? <span className="dsh-nx-settings-msg err" role="status">{t('saveFailed')}</span> ? <span className="dsh-nx-settings-msg err" role="status">{t('saveFailed')}</span>
: null} : null}
</div> </div> : null}
</section> </section>
) )
} }

View file

@ -3,18 +3,21 @@
export type NetxopsLocaleKey = export type NetxopsLocaleKey =
| 'settingsUnavailable' | 'settingsUnavailable'
| 'toolsEnabled' | 'toolsEnabled'
| 'toolsEnabledHint'
| 'toolsDisabled' | 'toolsDisabled'
| 'selectedPresets' | 'selectedPresets'
| 'publicationHint'
| 'presetCatalogUnavailable' | 'presetCatalogUnavailable'
| 'presetUnavailable' | 'presetUnavailable'
| 'noOtherPresets' | 'noOtherPresets'
| 'mountStatusHint' | 'mountStatusHint'
| 'mountDetails' | 'mountDetails'
| 'title' | 'title'
| 'description'
| 'sectionConnection' | 'sectionConnection'
| 'sectionLanguage'
| 'languageScope'
| 'languageAuto'
| 'languageFollowUser'
| 'languageChinese'
| 'languageEnglish'
| 'sectionCapabilities' | 'sectionCapabilities'
| 'sectionKnowledge' | 'sectionKnowledge'
| 'sectionAlarms' | 'sectionAlarms'
@ -23,14 +26,9 @@ export type NetxopsLocaleKey =
| 'apiUrlHint' | 'apiUrlHint'
| 'apiUrlInvalid' | 'apiUrlInvalid'
| 'lang' | 'lang'
| 'langHint'
| 'thinkingLanguage' | 'thinkingLanguage'
| 'thinkingLanguageHint'
| 'replyLanguage' | 'replyLanguage'
| 'replyLanguageHint'
| 'capabilityGroups'
| 'nmsProvider' | 'nmsProvider'
| 'nmsProviderHint'
| 'groupOps' | 'groupOps'
| 'groupTopology' | 'groupTopology'
| 'groupBizMonitor' | 'groupBizMonitor'
@ -42,12 +40,10 @@ export type NetxopsLocaleKey =
| 'kbBrowse' | 'kbBrowse'
| 'kbBrowseUnavailable' | 'kbBrowseUnavailable'
| 'kbBrowseFailed' | 'kbBrowseFailed'
| 'kbSaveHint'
| 'kbStatusConfigured' | 'kbStatusConfigured'
| 'kbStatusUnconfigured' | 'kbStatusUnconfigured'
| 'kbStatusError' | 'kbStatusError'
| 'alarmPushEnabled' | 'alarmPushEnabled'
| 'alarmPushStatus'
| 'alarmPushPhaseDisabled' | 'alarmPushPhaseDisabled'
| 'alarmPushPhaseIdle' | 'alarmPushPhaseIdle'
| 'alarmPushPhaseConnecting' | 'alarmPushPhaseConnecting'
@ -66,12 +62,10 @@ export type NetxopsLocaleKey =
| 'apiTokenUnavailable' | 'apiTokenUnavailable'
| 'apiTokenSet' | 'apiTokenSet'
| 'apiTokenUnset' | 'apiTokenUnset'
| 'toolsStatus'
| 'toolsStatusMounted' | 'toolsStatusMounted'
| 'toolsStatusEmpty' | 'toolsStatusEmpty'
| 'toolsStatusError' | 'toolsStatusError'
| 'toolsStatusUnknown' | 'toolsStatusUnknown'
| 'sessionsExport'
| 'sessionsExportCount' | 'sessionsExportCount'
| 'sessionsExportUnavailable' | 'sessionsExportUnavailable'
| 'sessionsExportNoPersistence' | 'sessionsExportNoPersistence'
@ -84,7 +78,6 @@ export type NetxopsLocaleKey =
| 'sessionsExportButton' | 'sessionsExportButton'
| 'sessionsExportBusy' | 'sessionsExportBusy'
| 'sessionsExportDone' | 'sessionsExportDone'
| 'overridden'
| 'reset' | 'reset'
| 'invalid' | 'invalid'
| 'unsaved' | 'unsaved'
@ -93,56 +86,51 @@ export type NetxopsLocaleKey =
| 'discard' | 'discard'
| 'save' | 'save'
| 'saving' | 'saving'
| 'configSaved'
export const en: Record<NetxopsLocaleKey, string> = { export const en: Record<NetxopsLocaleKey, string> = {
settingsUnavailable: 'Waiting for Host settings. Saving is disabled until connected. If this persists, check that the Host plugin is enabled.', settingsUnavailable: 'Connecting to Host settings…',
toolsEnabled: 'Publish Netx capabilities', toolsEnabled: 'Enable tools and skills',
toolsEnabledHint: 'Auto-saves. Turn off to withdraw all Netx tools and skills; your group selections are retained.', toolsDisabled: 'Disabled',
toolsDisabled: 'All Netx tools and skills are off', selectedPresets: 'Other presets',
selectedPresets: 'Selected other presets', presetCatalogUnavailable: 'Cannot load presets. Existing selections are retained.',
publicationHint: 'Tools and their skills are published together. Changes auto-save and apply to the next model request in current and new sessions. Public capabilities are inherited by every preset, including Netx Ops.',
presetCatalogUnavailable: 'Preset roster unavailable. Existing selections are retained and can be removed.',
presetUnavailable: 'Preset unavailable', presetUnavailable: 'Preset unavailable',
noOtherPresets: 'No other presets available on this Host.', noOtherPresets: 'No other presets available.',
mountStatusHint: 'Actual mounts on this Host. Selected other presets mount when a session is active. This is registration status; API access is checked during tool calls.', mountStatusHint: 'Registered tools in active sessions; API access is verified when a tool runs.',
mountDetails: 'Mount details', mountDetails: 'Diagnostics',
title: 'Netx Ops', title: 'Netx Ops',
description: 'API, token, capabilities, knowledge base, and alarm delivery.',
sectionConnection: 'Connection', sectionConnection: 'Connection',
sectionCapabilities: 'Capability groups', sectionLanguage: 'Model language',
languageScope: 'Applies to all presets',
languageAuto: 'Automatic',
languageFollowUser: 'Follow user',
languageChinese: 'Chinese',
languageEnglish: 'English',
sectionCapabilities: 'Tools and skills',
sectionKnowledge: 'Knowledge base', sectionKnowledge: 'Knowledge base',
sectionAlarms: 'Key-alarm delivery', sectionAlarms: 'Key-alarm delivery',
sectionExport: 'Session export', sectionExport: 'Session export',
apiUrl: 'API URL', apiUrl: 'API URL',
apiUrlHint: 'e.g. http://127.0.0.1:8890', apiUrlHint: 'http://127.0.0.1:8890',
apiUrlInvalid: 'Must be http(s) without username/password (no file:/javascript:)', apiUrlInvalid: 'Enter an HTTP or HTTPS URL without credentials.',
lang: 'API / alarm language', lang: 'API / alarm language',
langHint: 'zh / en — netx API query + alarm prompt copy only (not model reply)',
thinkingLanguage: 'Thinking language', thinkingLanguage: 'Thinking language',
thinkingLanguageHint: 'auto / zh-CN / en — model chain-of-thought for all presets',
replyLanguage: 'Reply language', replyLanguage: 'Reply language',
replyLanguageHint: 'follow-user / zh / en — final answers for all presets',
capabilityGroups: 'Capability groups',
nmsProvider: 'NMS provider', nmsProvider: 'NMS provider',
nmsProviderHint: 'zte-ume',
groupOps: 'Alarms, inventory and CLI', groupOps: 'Alarms, inventory and CLI',
groupTopology: 'Topology and layout', groupTopology: 'Topology and layout',
groupBizMonitor: 'Cutover monitoring', groupBizMonitor: 'Cutover monitoring',
groupKb: 'Knowledge skills', groupKb: 'Knowledge skills',
groupInPreset: 'In Netx Ops preset (auto-saves; mount/unmount immediately)', groupInPreset: 'Netx Ops',
groupPublic: 'Publish to every preset (auto-saves)', groupPublic: 'All presets',
kbRoot: 'Package root', kbRoot: 'Package root',
kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Browse auto-saves. Pack skills load from paths.skills/_skills and paths.localSkills when hasSkills.', kbRootHint: 'Absolute path to a folder containing MANIFEST.json',
kbBrowse: 'Browse…', kbBrowse: 'Browse…',
kbBrowseUnavailable: 'Directory picker unavailable — paste an absolute folder path, then Save.', kbBrowseUnavailable: 'Enter the folder path manually.',
kbBrowseFailed: 'Browse failed: {detail}', kbBrowseFailed: 'Browse failed: {detail}',
kbSaveHint: 'Browse writes and saves automatically, then reloads Host KB status. Manual paste still needs Save.', kbStatusConfigured: '{operator} ({country}) · v{version}',
kbStatusConfigured: 'Knowledge base: {operator} ({country}) v{version}', kbStatusUnconfigured: 'Not configured',
kbStatusUnconfigured: 'Knowledge base: not configured (pure netx)', kbStatusError: 'Load failed',
kbStatusError: 'Knowledge base: error — {detail}', alarmPushEnabled: 'Enable push',
alarmPushEnabled: 'Key-alarm push',
alarmPushStatus: 'Status',
alarmPushPhaseDisabled: 'Off', alarmPushPhaseDisabled: 'Off',
alarmPushPhaseIdle: 'Idle', alarmPushPhaseIdle: 'Idle',
alarmPushPhaseConnecting: 'Connecting…', alarmPushPhaseConnecting: 'Connecting…',
@ -153,33 +141,30 @@ export const en: Record<NetxopsLocaleKey, string> = {
alarmPushPhaseError: 'Error', alarmPushPhaseError: 'Error',
alarmDeliverDsh: 'Deliver to DSH session', alarmDeliverDsh: 'Deliver to DSH session',
alarmDeliverIm: 'Deliver to WhatsApp / IM', alarmDeliverIm: 'Deliver to WhatsApp / IM',
imTargetNone: 'No delivery targets yet — create one in IM delivery settings.', imTargetNone: 'No targets. Add one in IM settings.',
imTargetSelectedCount: '{count} selected', imTargetSelectedCount: '{count} selected',
imCatalogUnavailable: 'Cannot load IM targets (install/update dsh-im-ops).', imCatalogUnavailable: 'IM delivery unavailable',
apiToken: 'API token', apiToken: 'API token',
apiTokenHint: 'Stored as NETX_API_TOKEN. Leave blank to keep current.', apiTokenHint: 'Leave blank to keep the current token',
apiTokenUnavailable: 'remote.credentials unavailable — set NETX_API_TOKEN via script.', apiTokenUnavailable: 'Credential service unavailable',
apiTokenSet: 'Configured', apiTokenSet: 'Configured',
apiTokenUnset: 'Not set', apiTokenUnset: 'Not set',
toolsStatus: 'Agent tools', toolsStatusMounted: '{count} active tools',
toolsStatusMounted: '{count} netx__* tools (v{version})', toolsStatusEmpty: 'No active tools',
toolsStatusEmpty: 'No active Netx tool mounts', toolsStatusError: 'Publication failed',
toolsStatusError: 'Tool mount failed: {detail}', toolsStatusUnknown: 'Status unavailable',
toolsStatusUnknown: 'Host status unavailable',
sessionsExport: 'Export all sessions',
sessionsExportCount: '{count} sessions', sessionsExportCount: '{count} sessions',
sessionsExportUnavailable: 'Export unavailable', sessionsExportUnavailable: 'Export unavailable',
sessionsExportNoPersistence: 'Session store unavailable (JSONL backend required).', sessionsExportNoPersistence: 'Session store unavailable.',
sessionsExportNoRawArtifacts: 'This persistence backend cannot export raw session files.', sessionsExportNoRawArtifacts: 'This session store does not support export.',
sessionsExportListFailed: 'Failed to list sessions: {detail}', sessionsExportListFailed: 'Failed to list sessions: {detail}',
sessionsExportRpcUnavailable: 'Cannot reach Host RPC for export status.', sessionsExportRpcUnavailable: 'Export service unavailable.',
sessionsExportRpcFailed: 'Export status RPC failed.', sessionsExportRpcFailed: 'Cannot load export status.',
sessionsExportHttpFailed: 'Export failed: HTTP {status}{detail}', sessionsExportHttpFailed: 'Export failed: HTTP {status}{detail}',
sessionsExportEmpty: 'Export failed: empty ZIP body.', sessionsExportEmpty: 'Export failed: empty ZIP body.',
sessionsExportButton: 'Download ZIP', sessionsExportButton: 'Download ZIP',
sessionsExportBusy: 'Preparing…', sessionsExportBusy: 'Preparing…',
sessionsExportDone: 'Downloaded: {file}', sessionsExportDone: 'Downloaded: {file}',
overridden: 'Overridden',
reset: 'Reset', reset: 'Reset',
invalid: 'Invalid', invalid: 'Invalid',
unsaved: 'Unsaved changes', unsaved: 'Unsaved changes',
@ -188,57 +173,52 @@ export const en: Record<NetxopsLocaleKey, string> = {
discard: 'Discard', discard: 'Discard',
save: 'Save', save: 'Save',
saving: 'Saving…', saving: 'Saving…',
configSaved: 'Saved',
} }
export const zh: Record<NetxopsLocaleKey, string> = { export const zh: Record<NetxopsLocaleKey, string> = {
settingsUnavailable: '正在等待 Host 设置,连接就绪前不能保存。若持续等待,请检查 Host 插件是否启用。', settingsUnavailable: '正在连接 Host 设置…',
toolsEnabled: '启用 Netx 能力发布', toolsEnabled: '启用工具与技能',
toolsEnabledHint: '自动保存。关闭会撤销全部 Netx 工具和 skill,保留各组选择,便于随时恢复。', toolsDisabled: '已关闭',
toolsDisabled: '全部 Netx 工具和 skill 已关闭', selectedPresets: '其他预设',
selectedPresets: '指定其他预设', presetCatalogUnavailable: '无法加载预设列表,已有选择已保留。',
publicationHint: '工具与对应 skill 一起发布。选择自动保存,对当前及新会话的下一次模型请求生效。公开能力由所有预设继承,也包括 Netx Ops。',
presetCatalogUnavailable: '暂时无法读取预设列表,已有选择会保留,也可以取消。',
presetUnavailable: '预设不可用', presetUnavailable: '预设不可用',
noOtherPresets: '此 Host 暂无其他预设。', noOtherPresets: '暂无其他预设。',
mountStatusHint: '显示此 Host 的实际挂载。指定其他预设在会话活跃时挂载;此状态表示注册结果,API 权限在调用工具时验证。', mountStatusHint: '当前活跃会话中的工具注册情况;API 权限在调用时验证。',
mountDetails: '查看实际挂载明细', mountDetails: '诊断信息',
title: 'Netx Ops', title: 'Netx Ops',
description: 'API、Token、能力组、知识库与告警投递。',
sectionConnection: '连接', sectionConnection: '连接',
sectionCapabilities: '能力组', sectionLanguage: '模型语言',
languageScope: '适用于所有预设',
languageAuto: '自动',
languageFollowUser: '跟随用户',
languageChinese: '中文',
languageEnglish: 'English',
sectionCapabilities: '工具与技能',
sectionKnowledge: '知识库', sectionKnowledge: '知识库',
sectionAlarms: '关键告警投递', sectionAlarms: '关键告警投递',
sectionExport: '会话导出', sectionExport: '会话导出',
apiUrl: 'API 地址', apiUrl: 'API 地址',
apiUrlHint: '例如 http://127.0.0.1:8890', apiUrlHint: 'http://127.0.0.1:8890',
apiUrlInvalid: '须为无账号密码的 http(s) 地址(不支持 file:/javascript:)', apiUrlInvalid: '请输入不含账号密码的 HTTP 或 HTTPS 地址。',
lang: 'API / 告警语言', lang: 'API / 告警语言',
langHint: 'zh / en — 仅影响 netx API 与告警文案,不控制模型回复',
thinkingLanguage: '思考语言', thinkingLanguage: '思考语言',
thinkingLanguageHint: 'auto / zh-CN / en — 全预设模型思考过程语言',
replyLanguage: '回复语言', replyLanguage: '回复语言',
replyLanguageHint: 'follow-user / zh / en — 全预设最终回答语言',
capabilityGroups: '能力组',
nmsProvider: 'NMS 提供方', nmsProvider: 'NMS 提供方',
nmsProviderHint: 'zte-ume',
groupOps: '告警、资产与 CLI 运维', groupOps: '告警、资产与 CLI 运维',
groupTopology: '拓扑与布局', groupTopology: '拓扑与布局',
groupBizMonitor: '割接业务监控', groupBizMonitor: '割接业务监控',
groupKb: '知识库技能', groupKb: '知识库技能',
groupInPreset: '在 Netx Ops 预设中启用(自动保存,即时挂载/卸载)', groupInPreset: 'Netx Ops',
groupPublic: '对所有预设公开(自动保存)', groupPublic: '所有预设',
kbRoot: '知识包根目录', kbRoot: '知识包根目录',
kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存;hasSkills 时加载 paths.skills/_skills 与 paths.localSkills。', kbRootHint: '含 MANIFEST.json 的知识包绝对路径',
kbBrowse: '浏览…', kbBrowse: '浏览…',
kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对目录路径后点保存。', kbBrowseUnavailable: '请手动输入目录路径。',
kbBrowseFailed: '浏览失败:{detail}', kbBrowseFailed: '浏览失败:{detail}',
kbSaveHint: '点「浏览」选目录后会自动写入、保存,并刷新宿主知识库状态;若是手粘路径仍需点保存。', kbStatusConfigured: '{operator}({country})· v{version}',
kbStatusConfigured: '知识库: {operator}({country}) v{version}', kbStatusUnconfigured: '未配置',
kbStatusUnconfigured: '知识库: 未配置(纯 netx)', kbStatusError: '加载失败',
kbStatusError: '知识库: 错误 — {detail}', alarmPushEnabled: '启用推送',
alarmPushEnabled: '关键告警推送',
alarmPushStatus: '状态',
alarmPushPhaseDisabled: '未开启', alarmPushPhaseDisabled: '未开启',
alarmPushPhaseIdle: '空闲', alarmPushPhaseIdle: '空闲',
alarmPushPhaseConnecting: '连接中…', alarmPushPhaseConnecting: '连接中…',
@ -249,33 +229,30 @@ export const zh: Record<NetxopsLocaleKey, string> = {
alarmPushPhaseError: '异常', alarmPushPhaseError: '异常',
alarmDeliverDsh: '投递到 DSH 会话', alarmDeliverDsh: '投递到 DSH 会话',
alarmDeliverIm: '投递到 WhatsApp / IM', alarmDeliverIm: '投递到 WhatsApp / IM',
imTargetNone: '暂无投递目标 — 请先在 IM「投递设置」新建。', imTargetNone: '暂无目标,请在 IM 设置中添加。',
imTargetSelectedCount: '已选 {count} 个', imTargetSelectedCount: '已选 {count} 个',
imCatalogUnavailable: '无法加载投递目标(请安装/更新 dsh-im-ops)。', imCatalogUnavailable: 'IM 投递不可用',
apiToken: 'API Token', apiToken: 'API Token',
apiTokenHint: '写入凭据 NETX_API_TOKEN;留空表示保留已有。', apiTokenHint: '留空保留当前凭据',
apiTokenUnavailable: '未提供 remote.credentials — 请用脚本写入 NETX_API_TOKEN。', apiTokenUnavailable: '凭据服务不可用',
apiTokenSet: '已配置', apiTokenSet: '已配置',
apiTokenUnset: '未设置', apiTokenUnset: '未设置',
toolsStatus: 'Agent 工具', toolsStatusMounted: '{count} 个工具',
toolsStatusMounted: '已挂载 {count} 个 netx__*(v{version})', toolsStatusEmpty: '暂无活跃工具',
toolsStatusEmpty: '当前没有活跃的 Netx 工具挂载', toolsStatusError: '发布异常',
toolsStatusError: '工具挂载失败:{detail}', toolsStatusUnknown: '状态不可用',
toolsStatusUnknown: '无法读取 Host 状态',
sessionsExport: '导出全部会话',
sessionsExportCount: '{count} 个会话', sessionsExportCount: '{count} 个会话',
sessionsExportUnavailable: '无法导出', sessionsExportUnavailable: '无法导出',
sessionsExportNoPersistence: '会话持久化不可用(需要 JSONL 后端)。', sessionsExportNoPersistence: '会话存储不可用。',
sessionsExportNoRawArtifacts: '当前持久化后端不支持导出原始会话文件。', sessionsExportNoRawArtifacts: '当前会话存储不支持导出。',
sessionsExportListFailed: '列出会话失败:{detail}', sessionsExportListFailed: '列出会话失败:{detail}',
sessionsExportRpcUnavailable: '无法通过 Host RPC 查询导出状态。', sessionsExportRpcUnavailable: '导出服务不可用。',
sessionsExportRpcFailed: '导出状态 RPC 失败。', sessionsExportRpcFailed: '无法获取导出状态。',
sessionsExportHttpFailed: '导出失败:HTTP {status}{detail}', sessionsExportHttpFailed: '导出失败:HTTP {status}{detail}',
sessionsExportEmpty: '导出失败:ZIP 为空。', sessionsExportEmpty: '导出失败:ZIP 为空。',
sessionsExportButton: '下载 ZIP', sessionsExportButton: '下载 ZIP',
sessionsExportBusy: '准备中…', sessionsExportBusy: '准备中…',
sessionsExportDone: '已下载:{file}', sessionsExportDone: '已下载:{file}',
overridden: '已覆盖',
reset: '重置', reset: '重置',
invalid: '无效', invalid: '无效',
unsaved: '有未保存更改', unsaved: '有未保存更改',
@ -284,5 +261,4 @@ export const zh: Record<NetxopsLocaleKey, string> = {
discard: '丢弃', discard: '丢弃',
save: '保存', save: '保存',
saving: '保存中…', saving: '保存中…',
configSaved: '已保存',
} }

View file

@ -1,19 +1,23 @@
/** Inject shared Netx Ops settings-section styles once (uds-auth visual language). */ /** Shared settings styles, using the Host's theme tokens. */
const STYLE_ID = 'dsh-netxops-settings-css' const STYLE_ID = 'dsh-netxops-settings-css'
const CSS = ` const CSS = `
.dsh-nx-settings{box-sizing:border-box;display:flex;flex-direction:column;gap:20px;min-height:0;padding:4px 4px 24px;color:var(--dsw-alias-label-primary,#1f2329)} .dsh-nx-settings{box-sizing:border-box;display:flex;flex-direction:column;gap:16px;min-height:0;padding:4px 4px 24px;color:var(--dsw-alias-label-primary,#1f2329)}
.dsh-nx-settings>header{display:flex;flex-direction:column;gap:4px} .dsh-nx-settings>header{display:flex;flex-direction:column;gap:4px}
.dsh-nx-settings h2{margin:0;font-size:20px;font-weight:600;line-height:28px} .dsh-nx-settings h2{margin:0;font-size:20px;font-weight:600;line-height:28px}
.dsh-nx-settings-intro{margin:0;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-secondary,#646a73)}
.dsh-nx-settings-card{border:1px solid var(--dsw-alias-border-l2,#dee0e3);border-radius:12px;padding:16px;background:var(--dsw-alias-bg-layer-1,transparent)} .dsh-nx-settings-card{border:1px solid var(--dsw-alias-border-l2,#dee0e3);border-radius:12px;padding:16px;background:var(--dsw-alias-bg-layer-1,transparent)}
.dsh-nx-settings-card h3{margin:0 0 12px;font-size:15px;font-weight:600;line-height:22px} .dsh-nx-settings-card h3{margin:0 0 12px;font-size:15px;font-weight:600;line-height:22px}
.dsh-nx-card-head{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:14px}
.dsh-nx-card-head h3{margin:0}
.dsh-nx-settings-field{display:flex;flex-direction:column;gap:4px;margin-bottom:12px} .dsh-nx-settings-field{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}
.dsh-nx-settings-field:last-child{margin-bottom:0} .dsh-nx-settings-field:last-child{margin-bottom:0}
.dsh-nx-settings-field>label,.dsh-nx-field-label{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#646a73)} .dsh-nx-settings-field>label,.dsh-nx-field-label,.dsh-nx-field-head>label{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary,#646a73)}
.dsh-nx-field-head{display:flex;align-items:center;justify-content:space-between;gap:8px} .dsh-nx-field-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
.dsh-nx-field-head>label,.dsh-nx-field-head>.dsh-nx-field-label{flex:1;min-width:0} .dsh-nx-field-head>label,.dsh-nx-field-head>.dsh-nx-field-label{flex:1;min-width:0}
.dsh-nx-fieldGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(230px,100%),1fr));gap:12px 20px}
.dsh-nx-fieldGrid>.dsh-nx-settings-field{min-width:0;margin:0}
.dsh-nx-toggleField>.dsh-nx-field-head{min-height:28px}
.dsh-nx-settings-field input[type=text], .dsh-nx-settings-field input[type=text],
.dsh-nx-settings-field input[type=password], .dsh-nx-settings-field input[type=password],
.dsh-nx-settings-field input:not([type]), .dsh-nx-settings-field input:not([type]),
@ -25,12 +29,12 @@ const CSS = `
.dsh-nx-settings-field input:disabled, .dsh-nx-settings-field input:disabled,
.dsh-nx-settings-field select:disabled{opacity:.55;cursor:default} .dsh-nx-settings-field select:disabled{opacity:.55;cursor:default}
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-state-error-primary,#d54941)!important} .dsh-nx-inputInvalid{border-color:var(--dsw-alias-state-error-primary,#d54941)!important}
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#8f959e)} .dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;overflow-wrap:anywhere;color:var(--dsw-alias-label-tertiary,#8f959e)}
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-state-error-primary,#d54941)} .dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;overflow-wrap:anywhere;color:var(--dsw-alias-state-error-primary,#d54941)}
.dsh-nx-badges{display:inline-flex;align-items:center;gap:8px;flex:none} .dsh-nx-badges{display:inline-flex;align-items:center;gap:8px;flex:none}
.dsh-nx-badge{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;font-weight:500;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:var(--dsw-alias-label-secondary,#646a73)} .dsh-nx-badge{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;font-weight:500;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:var(--dsw-alias-label-secondary,#646a73)}
.dsh-nx-badgeMuted{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#8f959e)} .dsh-nx-badgeMuted{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#8f959e)}
.dsh-nx-status{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;font-weight:500;white-space:nowrap;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:var(--dsw-alias-label-tertiary,#8f959e)} .dsh-nx-status{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;font-weight:500;max-width:100%;overflow-wrap:anywhere;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:var(--dsw-alias-label-tertiary,#8f959e)}
.dsh-nx-statusOk{color:var(--dsw-alias-state-success-primary,#20a162);background:rgba(32,161,98,.12)} .dsh-nx-statusOk{color:var(--dsw-alias-state-success-primary,#20a162);background:rgba(32,161,98,.12)}
.dsh-nx-statusWarn{color:var(--dsw-alias-state-warn-primary,#d97706);background:rgba(217,119,6,.12)} .dsh-nx-statusWarn{color:var(--dsw-alias-state-warn-primary,#d97706);background:rgba(217,119,6,.12)}
.dsh-nx-statusErr{color:var(--dsw-alias-state-error-primary,#d54941);background:rgba(213,73,65,.12)} .dsh-nx-statusErr{color:var(--dsw-alias-state-error-primary,#d54941);background:rgba(213,73,65,.12)}
@ -38,22 +42,26 @@ const CSS = `
.dsh-nx-reset:hover:not(:disabled){opacity:.85} .dsh-nx-reset:hover:not(:disabled){opacity:.85}
.dsh-nx-reset:disabled{opacity:.4;cursor:default} .dsh-nx-reset:disabled{opacity:.4;cursor:default}
.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;margin:0;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329);cursor:pointer} .dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;margin:0;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329);cursor:pointer}
.dsh-nx-checkRow input{margin-top:3px;flex:none} .dsh-nx-checkRow input{margin:3px 0 0;flex:none}
.dsh-nx-groupBlock{display:flex;flex-direction:column;gap:8px;padding:4px 0 8px} .dsh-nx-settings input[type=checkbox]{accent-color:var(--dsw-alias-state-business-primary,#3370ff)}
.dsh-nx-groupBlock+.dsh-nx-groupBlock{border-top:1px solid var(--dsw-alias-border-l1,#eef0f3);padding-top:12px} .dsh-nx-groupBlock{display:flex;flex-direction:column;gap:10px;padding:12px 0}
.dsh-nx-groupTitle{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329)} .dsh-nx-groupBlock+.dsh-nx-groupBlock{border-top:1px solid var(--dsw-alias-border-l1,#eef0f3)}
.dsh-nx-groupChecks{display:flex;flex-direction:column;gap:8px;padding-left:2px} .dsh-nx-groupTitle{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329)}
.dsh-nx-targets{margin-top:10px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2,#dfe1e5)} .dsh-nx-groupChecks{display:flex;flex-wrap:wrap;gap:8px 24px}
.dsh-nx-targets{margin-top:0}
.dsh-nx-diagnostics{padding-top:12px;border-top:1px solid var(--dsw-alias-border-l1,#eef0f3)}
.dsh-nx-diagnostics[open] summary{margin-bottom:8px}
.dsh-nx-diagnostics p+p{margin-top:4px}
.dsh-nx-settings summary{cursor:pointer;font-size:12px;font-weight:500;line-height:1.6} .dsh-nx-settings summary{cursor:pointer;font-size:12px;font-weight:500;line-height:1.6}
.dsh-nx-targets[open] .dsh-nx-checkRow{padding:5px 0;flex-wrap:wrap} .dsh-nx-targets[open] .dsh-nx-checkRow{padding:5px 0;flex-wrap:wrap}
.dsh-nx-settings input:focus-visible,.dsh-nx-settings button:focus-visible,.dsh-nx-settings summary:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#3370ff);outline-offset:2px} .dsh-nx-settings input:focus-visible,.dsh-nx-settings button:focus-visible,.dsh-nx-settings summary:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#3370ff);outline-offset:2px}
.dsh-nx-settings>.dsh-nx-settings-actions{position:sticky;bottom:0;padding:12px;background:var(--dsw-alias-bg-base,#fff);border-top:1px solid var(--dsw-alias-border-l2,#dfe1e5);flex-wrap:wrap} .dsh-nx-settings>.dsh-nx-settings-actions{position:sticky;bottom:0;padding:12px;background:var(--dsw-alias-bg-base,#fff);border-top:1px solid var(--dsw-alias-border-l2,#dfe1e5);flex-wrap:wrap}
.dsh-nx-settings-actions{display:flex;align-items:center;gap:8px}
.dsh-nx-imTargetList{display:flex;flex-direction:column;gap:8px;padding:4px 0 2px} .dsh-nx-imTargetList{display:flex;flex-direction:column;gap:8px;padding:4px 0 2px}
.dsh-nx-pathRow{display:flex;gap:8px;align-items:stretch} .dsh-nx-pathRow{display:flex;gap:8px;align-items:stretch}
.dsh-nx-pathRow>input{flex:1;min-width:0} .dsh-nx-pathRow>input{flex:1;min-width:0}
.dsh-nx-pathRow>.dsh-nx-btn{flex:none;align-self:stretch} .dsh-nx-pathRow>.dsh-nx-btn{flex:none;align-self:stretch}
.dsh-nx-settings-msg{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)} .dsh-nx-settings-msg{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)}
.dsh-nx-settings-msg.ok{color:var(--dsw-alias-state-success-primary,#20a162)}
.dsh-nx-settings-msg.err{color:var(--dsw-alias-state-error-primary,#d54941)} .dsh-nx-settings-msg.err{color:var(--dsw-alias-state-error-primary,#d54941)}
.dsh-nx-settings-empty{padding:24px;text-align:center;color:var(--dsw-alias-label-tertiary,#8f959e);font-size:13px} .dsh-nx-settings-empty{padding:24px;text-align:center;color:var(--dsw-alias-label-tertiary,#8f959e);font-size:13px}
.dsh-nx-btn{appearance:none;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);border-radius:4px;padding:8px 12px;font:inherit;font-size:13px;line-height:20px;cursor:pointer;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:var(--dsw-alias-label-primary,#1f2329)} .dsh-nx-btn{appearance:none;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);border-radius:4px;padding:8px 12px;font:inherit;font-size:13px;line-height:20px;cursor:pointer;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:var(--dsw-alias-label-primary,#1f2329)}
@ -66,7 +74,11 @@ const CSS = `
export function ensureStyles(): void { export function ensureStyles(): void {
if (typeof document === 'undefined') return if (typeof document === 'undefined') return
if (document.getElementById(STYLE_ID)) return const existing = document.getElementById(STYLE_ID)
if (existing) {
if (existing.textContent !== CSS) existing.textContent = CSS
return
}
const tag = document.createElement('style') const tag = document.createElement('style')
tag.id = STYLE_ID tag.id = STYLE_ID
tag.textContent = CSS tag.textContent = CSS