✨ feat(client): 插件页改为三组列表与键值状态,权限带状态点

This commit is contained in:
pyh
2026-10-04 19:24:03 +08:00
parent 074cdccdb9
commit ec8661b317
5 changed files with 288 additions and 157 deletions
+92 -50
View File
@@ -6,6 +6,12 @@
* row's description) and `page` (the whole form). Everything visible here
* belongs to this package, including the platform-specific permission hint.
*
* The page is laid out as three groups — what to notify about, the system
* channel's own state, and what the plugin is doing right now. Each group keeps
* its controls in one place: a list whose rows carry the subject on the left and
* the control on the right, and a fact list for the readings, so the status
* block is scannable instead of being four sentences in one cell.
*
* @module @dsh-plugin/session-notify/client/ui/ConfigSection
*/
@@ -22,6 +28,7 @@ export function ConfigSection(props) {
const snapshot = useOwnStore(props.store)
const tr = seatTranslator(props, props.fallbackT)
if (props.view === 'summary') return h(React.Fragment, null, tr('settings.description'))
const permission = snapshot.permission
const supported = permission !== 'unsupported'
const failed = permission === 'denied' || snapshot.promptError !== ''
@@ -32,9 +39,19 @@ export function ConfigSection(props) {
: supported
? 'settings.permission.default'
: 'settings.permission.unsupported'
const permissionText = snapshot.promptError === ''
? `${tr(statusKey)}${permission === 'denied' || !supported ? ` · ${tr(`settings.permission.hint.${props.platform}`)}` : ''}`
: snapshot.promptError
const promptFailed = snapshot.promptError !== ''
const permissionText = promptFailed ? snapshot.promptError : tr(statusKey)
const dotClass = permission === 'granted'
? ''
: failed ? ' is-error' : supported ? ' is-warn' : ' is-muted'
const permissionNote = promptFailed
? ''
: permission === 'denied' || !supported
? tr(`settings.permission.hint.${props.platform}`)
: permission === 'granted'
? tr('config.testHint')
: tr('settings.permission.hint.plain')
const health = snapshot.health
const healthText = health.state === 'watching'
? tr('settings.health.watching')
@@ -44,55 +61,80 @@ export function ConfigSection(props) {
? tr('settings.health.error', { message: health.message })
: tr('settings.health.starting')
const healthFailed = health.state !== 'watching' && health.state !== 'starting'
const delivery = props.copy.deliveryFacts(snapshot.lastDelivery, tr)
return h(React.Fragment, null,
h('style', null, CSS),
h('div', { className: 'dsn-sections' },
// The page header already shows this bundle's title and description, so
// this section opens with what the header cannot say: how alerts are
// carried, and what this form changes.
h('section', { className: 'dsn-section' },
h('p', { className: 'dsn-section-desc' }, tr('config.intro'))),
h('section', { className: 'dsn-section' },
h('h3', { className: 'dsn-section-title' }, tr('config.section.kinds')),
KINDS.map((kind) => h(KindRow, {
key: kind,
label: tr(`settings.kind.${kind}`),
checked: snapshot.kinds[kind] === true,
disabled: false,
onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true),
})),
h('p', { className: 'dsn-section-desc' }, tr('config.saveNote'))),
h('section', { className: 'dsn-section' },
h('h3', { className: 'dsn-section-title' }, tr('config.section.permission')),
h('div', { className: 'dsn-row' },
h('div', { className: 'dsn-row-text' },
h('div', { className: 'dsn-page' },
// The page header already shows this bundle's title and description, so this
// lede says what the header cannot: how an alert travels, and that the
// channel is chosen at delivery time.
h('p', { className: 'dsn-lede' }, tr('config.intro')),
h('section', { className: 'dsn-group' },
h('h3', { className: 'dsn-group-title' }, tr('config.section.kinds')),
h('div', { className: 'dsn-list' },
KINDS.map((kind) => h(KindRow, {
key: kind,
label: tr(`settings.kind.${kind}`),
checked: snapshot.kinds[kind] === true,
disabled: false,
onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true),
}))),
h('p', { className: 'dsn-group-note' }, tr('config.saveNote'))),
h('section', { className: 'dsn-group' },
h('h3', { className: 'dsn-group-title' }, tr('config.section.permission')),
h('div', { className: 'dsn-list' },
h('div', { className: 'dsn-row' },
h('div', { className: 'dsn-row-text' },
h('span', {
className: 'dsn-state',
role: failed ? 'alert' : 'status',
},
h('span', { className: `dsn-dot${dotClass}` }),
permissionText),
h('p', { className: 'dsn-row-sub' }, permissionNote)),
h('button', {
type: 'button',
className: `dsn-button${supported && permission !== 'granted' ? ' is-primary' : ''}`,
disabled: !supported,
onClick: permission === 'granted' ? props.testSystem : props.ask,
}, tr(!supported
? 'settings.action.unsupported'
: permission === 'granted' ? 'settings.action.testSystem' : 'settings.action.allow'))))),
h('section', { className: 'dsn-group' },
h('h3', { className: 'dsn-group-title' }, tr('config.section.status')),
h('dl', { className: 'dsn-facts' },
h('dt', { className: 'dsn-fact-key' }, tr('config.diag.window')),
h('dd', { className: 'dsn-fact' },
h('p', { className: 'dsn-fact-value' }, tr(windowIsAway() ? 'config.diag.away' : 'config.diag.inFront'))),
h('dt', { className: 'dsn-fact-key' }, tr('config.diag.watch')),
h('dd', { className: 'dsn-fact' },
h('p', {
className: `dsn-status${failed ? ' dsn-status-error' : ''}`,
role: failed ? 'alert' : 'status',
}, permissionText)),
h('button', {
type: 'button',
className: 'dsn-button',
disabled: !supported,
onClick: permission === 'granted' ? props.testSystem : props.ask,
}, tr(!supported
? 'settings.action.unsupported'
: permission === 'granted' ? 'settings.action.testSystem' : 'settings.action.allow'))),
h('p', { className: 'dsn-section-desc' }, tr('config.testHint'))),
h('section', { className: 'dsn-section' },
h('h3', { className: 'dsn-section-title' }, tr('config.section.status')),
h('div', { className: 'dsn-row' },
h('div', { className: 'dsn-row-text' },
h('p', { className: 'dsn-status' }, `${tr('config.diag.window')}: ${tr(windowIsAway() ? 'config.diag.away' : 'config.diag.inFront')}`),
h('p', {
className: `dsn-status${healthFailed ? ' dsn-status-error' : ''}`,
className: `dsn-fact-value${healthFailed ? ' is-error' : ''}`,
role: healthFailed ? 'alert' : 'status',
}, healthText),
h('p', { className: 'dsn-status' }, props.copy.describeDelivery(snapshot.lastDelivery, tr)),
h('p', { className: 'dsn-status' }, props.copy.describeReplay(props.replayCount(), tr))),
h('button', {
type: 'button',
className: 'dsn-button',
onClick: props.testAny,
}, tr('settings.action.testAny'))))))
}, healthText)),
h('dt', { className: 'dsn-fact-key' }, tr('config.diag.last')),
h('dd', { className: 'dsn-fact' },
delivery === null
? h('p', { className: 'dsn-fact-value' }, tr('config.diag.none'))
: [
h('p', { key: 'line', className: 'dsn-fact-value' }, `${delivery.at} · ${delivery.outcome}`),
delivery.title === ''
? null
: h('p', { key: 'title', className: 'dsn-fact-title' }, delivery.title),
]),
h('dt', { className: 'dsn-fact-key' }, tr('config.diag.replay')),
h('dd', { className: 'dsn-fact' },
h('p', { className: 'dsn-fact-value' }, props.copy.replayValue(props.replayCount(), tr)))),
h('div', { className: 'dsn-list' },
h('div', { className: 'dsn-row is-action' },
h('button', {
type: 'button',
className: 'dsn-button',
onClick: props.testAny,
}, tr('settings.action.testAny')))))))
}
+33 -15
View File
@@ -3,11 +3,15 @@
*
* The popup copies the shipped toast surface (a fixed entry in the frame-wide
* overlay, the `--dsw-alias-toast-bg` / `--dsw-alias-toast-label` pair,
* `--dsw-radius-lg`, `--dsw-shadow-lv3`) and adds a title line above the text;
* the switch copies `Switch.module.css` behavior and proportion; the
* configuration sections follow the Plugin Manager page's own section rhythm
* (a column, 32px apart). Class names are renamed under `dsn-`; only the popup
* glyphs carry their own artwork colors.
* `--dsw-radius-lg`, `--dsw-shadow-lv3`) and adds a title line above the text.
*
* The settings page is a reading order, not a form dump: a lede that says how
* alerts travel, then one group per concern. Inside a group a title introduces a
* list whose rows put the subject on the left and the single control on the
* right, with hairlines only between rows; the status group is a two-column fact
* list, because four sentences stacked in one cell are not scannable. Class
* names are renamed under `dsn-`; only the popup glyphs carry their own artwork
* colors.
*
* A plugin must not import a Harness Client package, so the declarations are
* this package's own text and reference theme tokens only.
@@ -34,17 +38,29 @@ export const CSS = `
.dsn-toast-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-toast-label);opacity:.8;cursor:pointer}
.dsn-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);opacity:1}
.dsn-toast-close:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
.dsn-sections{display:flex;flex-direction:column;gap:24px}
.dsn-section{display:flex;flex-direction:column;gap:10px}
.dsn-section-title{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px}
.dsn-section-desc{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
.dsn-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
.dsn-row:last-child{border-bottom:0}
.dsn-page{display:flex;flex-direction:column;gap:28px;max-width:560px}
.dsn-lede{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
.dsn-group{display:flex;flex-direction:column;gap:2px}
.dsn-group-title{margin:0 0 8px;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px}
.dsn-group-note{margin:8px 0 0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
.dsn-list{display:flex;flex-direction:column}
.dsn-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0}
.dsn-row+.dsn-row{border-top:.5px solid var(--dsw-alias-border-l2)}
.dsn-row.is-action{justify-content:flex-end;padding:12px 0 0}
.dsn-row-text{display:flex;flex-direction:column;gap:2px;min-width:0}
.dsn-row-title{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
.dsn-row-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
.dsn-status{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
.dsn-status-error{color:var(--dsw-alias-state-error-primary)}
.dsn-row-sub{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
.dsn-state{display:inline-flex;align-items:center;gap:8px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
.dsn-dot{flex:none;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-success-primary)}
.dsn-dot.is-warn{background:var(--dsw-alias-state-warn-label)}
.dsn-dot.is-error{background:var(--dsw-alias-state-error-primary)}
.dsn-dot.is-muted{background:var(--dsw-alias-border-l3)}
.dsn-facts{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px 14px;margin:0;min-width:0}
.dsn-fact-key{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;white-space:nowrap}
.dsn-fact{display:flex;flex-direction:column;gap:2px;margin:0;min-width:0}
.dsn-fact-value{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;overflow-wrap:anywhere}
.dsn-fact-value.is-error{color:var(--dsw-alias-state-error-primary)}
.dsn-fact-title{margin:0;color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;overflow-wrap:anywhere}
.dsn-switch{box-sizing:border-box;position:relative;flex:0 0 auto;width:36px;height:20px;padding:2px;border:0;border-radius:999px;background:var(--dsw-alias-border-l3);cursor:pointer}
.dsn-switch[aria-checked='true']{background:var(--dsw-alias-brand-primary)}
.dsn-switch:disabled{cursor:default;opacity:.5}
@@ -52,8 +68,10 @@ export const CSS = `
.dsn-thumb{display:block;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground);transition:transform 120ms ease}
.dsn-switch[aria-checked='false'] .dsn-thumb{background:var(--dsw-alias-switch-thumb)}
.dsn-switch[aria-checked='true'] .dsn-thumb{transform:translateX(16px)}
.dsn-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:36px;padding:0 14px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);cursor:pointer;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);background:transparent;flex:none}
.dsn-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:32px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);cursor:pointer;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);background:transparent;flex:none}
.dsn-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
.dsn-button:disabled{cursor:not-allowed;opacity:.4}
.dsn-button:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
.dsn-button.is-primary{border-color:transparent;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground)}
.dsn-button.is-primary:hover:not(:disabled){background:var(--dsw-alias-brand-primary);opacity:.88}
`