使用者选人(前端):选人控件按池边输入边取候选;个人渠道的实例事件改叫「我发起的实例完成 / 失败」

为什么:使用者读不到名录,选人控件只能凭记忆手填用户名,填错了服务端以前也照收(05-已知遗留 (b))。服务端已按用途给候选池、
开登录时按同一个池核对(上上个提交),前端要把候选给到人手上。另外个人渠道勾「实例完成 / 失败」以前永远收不到,服务端修好之后
(驳回那个提交里的 notify_run),名字要照实写成只推自己发起的实例(04-对外集成与触达 B1)。

做了什么:
- PrincipalPicker 加 source(CandidateSource:form 带 app_id+entry 或 task_id 和 field,review)。搭建者及以上照旧用整份名录;
  使用者开了登录、给了 source 时边输入边按开头取候选(250 毫秒防抖),列表里显示显示名、标签悬停给用户名;加的时候按同一个池
  问一次服务端,不在池里的当场说「「x」不在可选的人里」(问不到就先收下,提交时由服务端判);池要先输开头才列、列不全时照实提示。
- SchemaForm 按表单用在哪(入口、待办,同上传文件的上下文)给选人控件 source;个人应用提交审核用 purpose=review。
- typesApp:CandidateSource、Candidates、FormField 的 x-principal、FormError 的 principal 码(和服务端 FORM_ERROR_CODES 同序)。
- api.identity.candidates。termsApp 的 PRINCIPAL_TEXT 加三句。
- 设置 → 通知:范围是「仅自己」的渠道,run.finished / run.failed 写「我发起的实例完成 / 失败」(termsApp.NOTIFY_PERSONAL_EVENT_LABEL)。
- 服务端 x-principal 写法不对时的说法改成不带半角冒号接中文的写法(check-copy)。
This commit is contained in:
ThearchyHelios
2026-10-16 10:54:30 +08:00
parent 2fa2db3ea0
commit fbb671a96b
8 changed files with 132 additions and 24 deletions
+2 -2
View File
@@ -119,14 +119,14 @@ POOL_SLOT = "slot:"
def pool_problem(value: Any) -> str | None:
"""x-principal 的写法对不对:只能是 {pool: app | group:<组> | slot:<岗位>}。没问题为 None。"""
if not isinstance(value, dict) or set(value) != {"pool"} or not isinstance(value.get("pool"), str):
return "的候选范围只能写 {\"pool\": \"app\"}、{\"pool\": \"group:组名\"} 或 {\"pool\": \"slot:岗位\"}"
return "的候选范围只能写成 {\"pool\": \"app\"}、{\"pool\": \"group:<组名>\"} 或 {\"pool\": \"slot:<岗位>\"}"
pool = value["pool"].strip()
if pool == POOL_APP:
return None
for prefix in (POOL_GROUP, POOL_SLOT):
if pool.startswith(prefix) and pool[len(prefix):].strip():
return None
return "的候选范围只能是 app(能用这个应用的人)、group:组名 或 slot:岗位"
return "的候选范围只能是 app(能用这个应用的人)、group:<组名> 或 slot:<岗位>"
def pool_of(field: Any) -> str:
+4 -1
View File
@@ -2,7 +2,7 @@
* 身份的接口(/api/auth、/api/users、/api/groups、/api/audit),挂在 api.auth / api.identity 上。形状见 typesApp.ts。
* 开登录时:没登录的请求回 401(前端跳 /login),角色不够回 403。关登录时 /me 恒为 mode='off'。
*/
import type { AuditRow, AuthMode, Directory, GroupRow, Principal, Role, UserRow } from '../typesApp'
import type { AuditRow, AuthMode, CandidateSource, Candidates, Directory, GroupRow, Principal, Role, UserRow } from '../typesApp'
import { del, get, patch, post, put, qs, type RequestOptions } from './http'
export const auth = {
@@ -29,6 +29,9 @@ export const identity = {
groups: () => get<{ items: GroupRow[] }>('/groups'),
/** 选人用的名录:搭建者及以上只读(/users、/groups 只给管理员) */
directory: () => get<Directory>('/directory'),
/** 使用者选人的候选:按用途给的池,只有名字(任何登录的人都能调;用不了那个应用的 403)。q 按开头筛 */
candidates: (source: CandidateSource, q?: string, limit = 20) =>
get<Candidates>(`/directory/candidates${qs({ ...source, q: q || undefined, limit })}`),
createGroup: (body: { name: string; label?: string }) => post<GroupRow>('/groups', body),
setMembers: (id: string, members: string[]) => put<GroupRow>(`/groups/${encodeURIComponent(id)}/members`, { members }),
deleteGroup: (id: string) => del(`/groups/${encodeURIComponent(id)}`),
+67 -14
View File
@@ -5,15 +5,18 @@
* 名录里没有的、停用了的不让添加,说清是哪一个(账号密码登录时人和组都在本系统里建,名录里没有就是填错了);单点登录的
* 人和组第一次登录后才进名录,名录里没有的照实提醒,点「仍然添加」才加上。已选的里头名录里没有的、停用了的在标签上
* 标出来,不静默放着。
* 使用者读不到名录:照填,服务端按名字认。关登录时没有用户表,只能手填名字——签批记的也是各自填的署名,这里不假装能核对。
* 使用者读不到名录(只读与最小可见):给了 source 的(表单里的选人控件、个人应用提交审核)边输入边按开头取候选
* (GET /api/directory/candidates,05-已知遗留 (b)),只有这件事能选的人、只有名字;加的时候按同一个池问一次服务端,不在池里的
* 当场说清。提交时服务端照同一个池核对(不在池里 422),这里只是提前说。没给 source 的照填,服务端按名字认。
* 关登录时没有用户表,只能手填名字——签批记的也是各自填的署名,这里不假装能核对。
*/
import { useEffect, useId, useState } from 'react'
import { useEffect, useId, useRef, useState } from 'react'
import { Plus, Users, UserRound, X } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
import { PRINCIPAL_TEXT } from '../lib/termsApp'
import { useAtLeast, useAuth, useLoginOn } from '../store/auth'
import type { Directory } from '../typesApp'
import type { CandidateSource, Candidates, Directory } from '../typesApp'
import { isComposing } from './ui'
export interface Principal {
@@ -29,8 +32,16 @@ export interface PrincipalPickerProps {
/** 只能选一个 */
single?: boolean
label?: string
/**
* 使用者取候选的池(服务端按用途算):表单里的选人控件(入口、待办)、个人应用提交审核。搭建者及以上照旧用整份名录,
* 不看它
*/
source?: CandidateSource
}
/** 边输入边取候选:停手这么久再去问,不是每敲一个字问一次 */
const ASK_DELAY_MS = 250
/** 名录只取一次,同一页上几个选择器共用;没取到(断线)下次打开再取 */
let directory: Promise<Directory | null> | null = null
function loadDirectory(): Promise<Directory | null> {
@@ -46,15 +57,21 @@ function problemOf(p: Principal, known: Directory | null): 'missing' | 'disabled
return !user ? 'missing' : user.disabled ? 'disabled' : null
}
export function PrincipalPicker({ value, onChange, allowEveryone = false, single = false, label }: PrincipalPickerProps) {
export function PrincipalPicker({ value, onChange, allowEveryone = false, single = false, label, source }: PrincipalPickerProps) {
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const loginOn = useLoginOn()
// 名录给搭建者及以上(使用者读不到,不去取:取了也是 403)
const listable = useAtLeast('builder') && loginOn
// 使用者:有 source 就按池取候选
const asking = !listable && loginOn && !!source
const sourceKey = source ? JSON.stringify(source) : ''
const oidc = useAuth((s) => s.me?.mode === 'oidc')
const [kind, setKind] = useState<'user' | 'group'>('user')
const [text, setText] = useState('')
const [known, setKnown] = useState<Directory | null>(null)
const [pool, setPool] = useState<Candidates | null>(null)
/** 取到过的候选:用户名 → 显示名(标签上写显示名,悬停给用户名) */
const shown = useRef(new Map<string, string>())
/** 刚才要加、名录里对不上的那一项:soft 是单点登录下名录里还没有(可以「仍然添加」),其余不让加 */
const [refused, setRefused] = useState<{ item: Principal; message: string; soft: boolean } | null>(null)
useEffect(() => {
@@ -63,12 +80,38 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single
void loadDirectory().then((d) => { if (alive) setKnown(d) })
return () => { alive = false }
}, [listable])
useEffect(() => {
if (!asking || !source) return
let alive = true
const timer = window.setTimeout(() => {
api.identity.candidates(source, text.trim())
.then((c) => {
if (!alive) return
for (const u of c.users) shown.current.set(u.username, u.display_name)
setPool(c)
})
.catch(() => { if (alive) setPool(null) })
}, ASK_DELAY_MS)
return () => { alive = false; window.clearTimeout(timer) }
// sourceKey 代替 source:调用方每次渲染都给一个新对象
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [asking, sourceKey, text])
const has = (p: Principal) => value.some((x) => x.kind === p.kind && x.name === p.name)
const add = (p: Principal, anyway = false) => {
const add = async (p: Principal, anyway = false) => {
const item = { ...p, name: p.name.trim() }
if (!item.name) return
if (has(item)) { setText(''); setRefused(null); return }
if (asking && source && item.kind !== 'everyone') {
// 按同一个池问一次服务端:不在池里的当场说清(提交时服务端照样拒)。问不到(断线)就先收下,提交时由服务端判
const found = await api.identity.candidates(source, item.name, 50).catch(() => null)
const listed = found && (item.kind === 'group' ? found.groups.some((g) => g.name === item.name)
: found.users.some((u) => u.username === item.name))
if (found && !listed) {
setRefused({ item, soft: false, message: PRINCIPAL_TEXT.notCandidate(item.name) })
return
}
}
const problem = problemOf(item, known)
const soft = problem === 'missing' && oidc
if (problem && !(soft && anyway)) {
@@ -87,9 +130,16 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single
const remove = (p: Principal) => onChange(value.filter((x) => !(x.kind === p.kind && x.name === p.name)))
const listId = `${uid}-list`
const errorId = `${uid}-error`
const options = !known ? [] : kind === 'user'
? known.users.filter((u) => !u.disabled).map((u) => ({ value: u.username, label: u.display_name }))
: known.groups.map((g) => ({ value: g.name, label: g.label }))
const options = asking
? (!pool ? [] : kind === 'user' ? pool.users.map((u) => ({ value: u.username, label: u.display_name }))
: pool.groups.map((g) => ({ value: g.name, label: g.label })))
: !known ? [] : kind === 'user'
? known.users.filter((u) => !u.disabled).map((u) => ({ value: u.username, label: u.display_name }))
: known.groups.map((g) => ({ value: g.name, label: g.label }))
/** 候选池要先输入开头才列(应用授权给了所有人)、列不全(超过上限) */
const poolHint = asking && pool
? (pool.needs_query && !text.trim() ? PRINCIPAL_TEXT.typeToSearch : pool.truncated ? PRINCIPAL_TEXT.truncated : null)
: null
return (
<div className="min-w-0" data-principal-picker="">
@@ -102,7 +152,9 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single
data-principal-problem={problem ?? undefined}
style={problem ? { color: 'var(--st-waiting)', borderColor: 'var(--st-waiting)' } : undefined}>
{p.kind === 'group' ? <Users size={11} aria-hidden /> : p.kind === 'user' ? <UserRound size={11} aria-hidden /> : null}
<span>{p.kind === 'everyone' ? PRINCIPAL_TEXT.everyone : p.name}</span>
<span title={p.kind === 'user' && shown.current.get(p.name) ? p.name : undefined}>
{p.kind === 'everyone' ? PRINCIPAL_TEXT.everyone : shown.current.get(p.name) ?? p.name}
</span>
<span className="sr-only">({PRINCIPAL_TEXT[p.kind]})</span>
{problem && <span className="text-2xs">· {problem === 'disabled' ? PRINCIPAL_TEXT.disabledTag : PRINCIPAL_TEXT.missingTag}</span>}
<button type="button" className="-mr-0.5 rounded text-faint hover:text-fg" aria-label={PRINCIPAL_TEXT.remove(p.kind === 'everyone' ? PRINCIPAL_TEXT.everyone : p.name)}
@@ -127,21 +179,21 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single
onKeyDown={(e) => {
if (e.key !== 'Enter' || isComposing(e)) return
e.preventDefault()
add({ kind, name: text })
void add({ kind, name: text })
}}
// 单选时填完就算选好,不用再点「添加」:表单里的选人控件和普通输入框一样,失焦即生效
onBlur={() => { if (single && text.trim()) add({ kind, name: text }) }} />
onBlur={() => { if (single && text.trim()) void add({ kind, name: text }) }} />
{options.length > 0 && (
<datalist id={listId}>
{options.map((o) => <option key={o.value} value={o.value}>{o.label !== o.value ? o.label : undefined}</option>)}
</datalist>
)}
<button type="button" className="btn btn-sm shrink-0" disabled={!text.trim()} onClick={() => add({ kind, name: text })}>
<button type="button" className="btn btn-sm shrink-0" disabled={!text.trim()} onClick={() => void add({ kind, name: text })}>
<Plus size={12} aria-hidden /> {PRINCIPAL_TEXT.add}
</button>
{allowEveryone && (
<button type="button" className={clsx('btn btn-sm shrink-0', has({ kind: 'everyone', name: '*' }) && 'opacity-50')}
disabled={has({ kind: 'everyone', name: '*' })} onClick={() => add({ kind: 'everyone', name: '*' })}>
disabled={has({ kind: 'everyone', name: '*' })} onClick={() => void add({ kind: 'everyone', name: '*' })}>
{PRINCIPAL_TEXT.everyone}
</button>
)}
@@ -151,12 +203,13 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single
style={{ color: refused.soft ? 'var(--st-waiting)' : 'var(--err)' }} data-principal-refused={refused.soft ? 'soft' : 'hard'}>
<span>{refused.message}</span>
{refused.soft && (
<button type="button" className="btn btn-xs" onClick={() => add(refused.item, true)} data-principal-add-anyway="">
<button type="button" className="btn btn-xs" onClick={() => void add(refused.item, true)} data-principal-add-anyway="">
{PRINCIPAL_TEXT.addAnyway}
</button>
)}
</div>
)}
{poolHint && <div className="mt-1 text-2xs text-faint" data-principal-pool-hint="">{poolHint}</div>}
{!loginOn && <div className="mt-1 text-2xs text-faint">{PRINCIPAL_TEXT.manualHint}</div>}
</div>
)
+12 -2
View File
@@ -12,7 +12,7 @@
import { useEffect, useId, useState } from 'react'
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'
import clsx from 'clsx'
import type { FormError, FormField, FormSchema } from '../typesApp'
import type { CandidateSource, FormError, FormField, FormSchema } from '../typesApp'
import { FORM_TEXT } from '../lib/termsApp'
import {
fieldOfPath, fieldOrder, fileLimitsOf, localToRfc3339, optionsOf, rfc3339ToLocal, widgetOf,
@@ -88,6 +88,16 @@ export function SchemaForm({ schema, value, onChange, errors, readOnly, onSubmit
)
}
/**
* 选人控件的候选从哪个池取:表单用在入口(app_id + entry)、待办(task_id)上时,按这个字段问服务端(05-已知遗留 (b));
* 蓝图试运行之类没有应用的地方不给(搭建者用整份名录)
*/
function principalSource(context: Record<string, string> | undefined, field: string): CandidateSource | undefined {
if (context?.task_id) return { purpose: 'form', task_id: context.task_id, field }
if (context?.app_id && context.entry) return { purpose: 'form', app_id: context.app_id, entry: context.entry, field }
return undefined
}
function FieldRow({ id, name, field, required, value, error, readOnly, onChange, fileContext }: {
id: string; name: string; field: FormField; required: boolean; value: unknown; error?: FormError
readOnly: boolean; onChange: (v: unknown) => void; fileContext?: Record<string, string>
@@ -143,7 +153,7 @@ function FieldRow({ id, name, field, required, value, error, readOnly, onChange,
<div className="label">{label}</div>
{readOnly
? <div className="text-xs text-fg">{typeof value === 'string' && value ? value : '—'}</div>
: <PrincipalPicker single label={title}
: <PrincipalPicker single label={title} source={principalSource(fileContext, name)}
value={typeof value === 'string' && value ? [{ kind: 'user', name: value }] : []}
onChange={(list) => onChange(list[0]?.name || undefined)} />}
{foot}
+13
View File
@@ -368,6 +368,10 @@ export const PRINCIPAL_TEXT = {
/** 已选的那一项不在名录里、或已停用:标在标签上 */
missingTag: '名录里没有',
disabledTag: '已停用',
/** 使用者选人:候选按这件事能选的人给(服务端 auth/directory 的候选池,05-已知遗留 (b)) */
notCandidate: (name: string) => `「${name}」不在可选的人里,请从候选中选择`,
typeToSearch: '能选的人较多:输入名字的开头查找',
truncated: '只列出了一部分:多输入几个字缩小范围',
}
/** 身份:登录、账户菜单、角色(§4.4 AUTH_TEXT) */
@@ -445,6 +449,15 @@ export const SIGNER_TEXT = {
record: '记入留痕',
}
/**
* 个人渠道(设置 → 通知,范围是「仅自己」)里实例结束的两个事件:只推自己发起、而且是登录身份发起的实例(服务端
* notify/service.notify_run,04-对外集成与触达 B1)。以前叫「实例完成 / 失败」,勾了却永远收不到
*/
export const NOTIFY_PERSONAL_EVENT_LABEL: Record<string, string> = {
'run.finished': '我发起的实例完成',
'run.failed': '我发起的实例失败',
}
/** 发布弹窗按角色收起的档位(Q8:受管级发布只给管理员,服务端 api/workflows 同样拒) */
export const PUBLISH_ROLE_TEXT = {
governedAdminOnly: '需要管理员:以受管级发布只给管理员,请联系管理员发布',
+1 -1
View File
@@ -171,7 +171,7 @@ function ReviewDialog({ appId, onClose, onSent }: {
<p className="text-xs leading-relaxed text-dim">{T.reviewIntro}</p>
<div>
<div className="label"><UserRound size={11} aria-hidden className="mr-1 inline" />{T.reviewTo}</div>
<PrincipalPicker single value={to} onChange={setTo} label={T.reviewTo} />
<PrincipalPicker single value={to} onChange={setTo} label={T.reviewTo} source={{ purpose: 'review' }} />
<p className="mt-1 text-2xs text-faint">{T.reviewToHint}</p>
</div>
<div>
+6 -2
View File
@@ -6,6 +6,7 @@ import {
} from '../../components/ui'
import { formatDateTime } from '../../lib/format'
import { NOTIFY_TEXT as T, SETTINGS_ROLE_TEXT } from '../../lib/terms'
import { NOTIFY_PERSONAL_EVENT_LABEL } from '../../lib/termsApp'
import { useAtLeast } from '../../store/auth'
import type { NotifyChannelRow } from '../../typesApp'
@@ -18,6 +19,9 @@ import type { NotifyChannelRow } from '../../typesApp'
const FORMATS = ['generic', 'feishu', 'wecom', 'dingtalk'] as const
const EVENTS = Object.keys(T.eventLabel)
/** 事件在这个范围的渠道上怎么叫:个人渠道的实例结束只推自己发起的,叫「我发起的实例完成 / 失败」 */
const eventLabel = (event: string, scope: string): string =>
(scope === 'user' ? NOTIFY_PERSONAL_EVENT_LABEL[event] : undefined) ?? T.eventLabel[event] ?? event
export function NotifyTab() {
const builder = useAtLeast('builder')
@@ -86,7 +90,7 @@ export function NotifyTab() {
</div>
<div className="truncate text-2xs text-faint">
<span className="mono">{row.url_host}</span>
{row.events.length > 0 && ` · ${row.events.map((e) => T.eventLabel[e] ?? e).join('、')}`}
{row.events.length > 0 && ` · ${row.events.map((e) => eventLabel(e, row.scope)).join('、')}`}
</div>
</div>
<label className="flex items-center gap-1 text-2xs">
@@ -211,7 +215,7 @@ function ChannelEditor({ row, onClose, onSaved }: {
<label key={ev} className="flex items-center gap-1.5 text-xs">
<input type="checkbox" className="accent-[var(--accent)]" checked={form.events.includes(ev)}
onChange={(e) => setForm({ ...form, events: e.target.checked ? [...form.events, ev] : form.events.filter((x) => x !== ev) })} />
{T.eventLabel[ev]}
{eventLabel(ev, form.scope)}
</label>
))}
</div>
+27 -2
View File
@@ -54,6 +54,25 @@ export interface Directory {
groups: { name: string; label: string }[]
}
/**
* 使用者选人的候选从哪个池取(GET /api/directory/candidates,05-已知遗留 (b)):form 是表单里的选人控件(入口表单带 app_id、
* entry,待办表单带 task_id,都带 field),review 是个人应用提交审核
*/
export type CandidateSource =
| { purpose: 'form'; app_id?: string; entry?: string; task_id?: string; field: string }
| { purpose: 'review' }
/**
* 候选池给的那一份:只有用户名和显示名(不给是否停用、角色、邮箱,停用的不列)。needs_query:应用授权给了所有人,
* 不输入开头不列;truncated:符合的超过了上限(最多 50 位)
*/
export interface Candidates {
users: { username: string; display_name: string }[]
groups: { name: string; label: string }[]
truncated: boolean
needs_query?: boolean
}
export interface AuditRow {
id: string
at: string
@@ -100,6 +119,11 @@ export interface FormField {
'x-widget'?: FormWidget
/** 文件控件的限制,只配 x-widget: file */
'x-file'?: FileLimits
/**
* 选人控件的候选池,只配 x-widget: principal:app(能用这个应用的人,缺省)、group:<组>、slot:<岗位>
* (服务端 auth/directory.pool_problem,05-已知遗留 (b))
*/
'x-principal'?: { pool: string }
}
export interface FormSchema {
@@ -113,11 +137,12 @@ export interface FormSchema {
/**
* 前后端同一种错误格式(服务端 apps/spec.FORM_ERROR_CODES)。file:文件控件的值不是上传后拿回的引用、类型或大小不合
* x-file 的限制,或提交时核对引用没过(不是这个人传的、已经用过)
* x-file 的限制,或提交时核对引用没过(不是这个人传的、已经用过);principal:开了登录时选人控件的值不在候选池里
* (服务端 auth/directory.principal_errors,05-已知遗留 (b))
*/
export interface FormError {
path: string
code: 'required' | 'type' | 'enum' | 'minimum' | 'maximum' | 'min_length' | 'max_length' | 'max_items' | 'format' | 'file'
code: 'required' | 'type' | 'enum' | 'minimum' | 'maximum' | 'min_length' | 'max_length' | 'max_items' | 'format' | 'file' | 'principal'
message: string
}