W3-d 邮件、待办汇总、分享链接的界面

- 设置 → 通知:渠道多一种格式「邮件」,邮件服务没启用时不列;个人邮件渠道不填地址,写「发到账号邮箱 …」(账号没有邮箱照实写
  发不出去),全局、应用的填收件地址;投递记录里邮件成功写「已交给邮件服务器」、悬停说明退信不追踪;新增「邮件服务」一节
  (管理员改、搭建者只看:口令留空不改、可清除,发测试邮件要先保存)和「每天的待办汇总」的发送时间
- 设置 → 偏好设置的「应用」一节:「允许建分享链接」开关(缺省关,写明关掉时已有的一并打不开)
- 应用编辑 → 岗位与授权:分享链接的管理(选页面、始终最新 / 固定这一期、有效期 1–90 天、允许嵌入;链接只显示这一次;
  列出状态、到期、打开次数,撤销先确认);开关关着时不给新建,已有的照列能撤销;个人应用写明不能分享
- /s 分享页:不进外壳(App 在 /s 上整个跳过认人、取目录、心跳、读主题,请求层在 /s 上也不送去登录页),key 从 # 后面读、
  放进请求头;同一个 ViewRenderer 渲染、操作全禁用;印章常显(「数据的来历」逐个列视图的印章);点数字说明出处在下载的
  报告里;页脚「只读 · 由 X 于 … 分享 · … 到期」;撤销、到期、关了开关、链接不完整按服务端的话写明;没允许嵌入的分享在别的
  页面的 iframe 里不显示内容、给「在新窗口打开」
- ViewRenderer 加 onEvidence:点数字交给调用方(分享页),不开在线证据面板
- check-copy 加「快照链接」一条(总方案 X10:写「分享链接」),带自检
This commit is contained in:
ThearchyHelios
2026-10-16 16:44:06 +08:00
parent f759c5c82d
commit f887cdc30a
16 changed files with 936 additions and 51 deletions
+1 -1
View File
@@ -646,7 +646,7 @@ def notify_problem(value: Any) -> str | None:
from app.notify.digest import time_ok from app.notify.digest import time_ok
if not time_ok(item): if not time_ok(item):
return f"「{NOTIFY_SETTING_LABEL[key]}」需要写成 时:分(00:00 到 23:59,例如 09:00),当前为「{item}」" return f"「{NOTIFY_SETTING_LABEL[key]}」需要是 24 小时制的时间,例如 09:00(从 00:00 到 23:59),当前为「{item}」"
elif problem := _base_url_problem(item): elif problem := _base_url_problem(item):
return problem return problem
return None return None
+11
View File
@@ -32,6 +32,7 @@ import { NewAppStart } from './pages/blueprints/NewAppStart'
import { BlueprintPage } from './pages/blueprints/BlueprintPage' import { BlueprintPage } from './pages/blueprints/BlueprintPage'
import { personalAppsOn } from './pages/blueprints/shared' import { personalAppsOn } from './pages/blueprints/shared'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
import { SharePage } from './pages/apps/SharePage'
import { Logo } from './components/Logo' import { Logo } from './components/Logo'
import { import {
CommandPalette, PAGES, ShortcutHelp, flushThemePref, openShortcutHelp, toggleCommandPalette, toggleNotify, CommandPalette, PAGES, ShortcutHelp, flushThemePref, openShortcutHelp, toggleCommandPalette, toggleNotify,
@@ -56,7 +57,17 @@ import { APP_TEXT, AUTH_MODE_LABEL, AUTH_TEXT, ROLE_LABEL, RUN_COMPARE_TEXT, TAS
/** 启动时认人(/auth/me)最多等多久再取目录 */ /** 启动时认人(/auth/me)最多等多久再取目录 */
const AUTH_WAIT_MS = 2500 const AUTH_WAIT_MS = 2500
/**
* 分享页(/s#<分享 key>,04 细案 C2)不在外壳里,比登录页还早一步分开:拿到链接的人没有登录这回事,外壳的认人、取目录、
* 心跳、读主题一样都不做(开了登录时它们会 401,再被请求层送去登录页)。整页加载时定一次:分享页上没有通往外壳的链接
*/
const SHARE_PAGE = typeof window !== 'undefined' && window.location.pathname === '/s'
export default function App() { export default function App() {
return SHARE_PAGE ? <SharePage /> : <Shell />
}
function Shell() {
const loaded = useCatalog((s) => s.loaded) const loaded = useCatalog((s) => s.loaded)
const backend = useCatalog((s) => s.backend) const backend = useCatalog((s) => s.backend)
const everOk = useCatalog((s) => s.lastOkAt != null) const everOk = useCatalog((s) => s.lastOkAt != null)
+12 -2
View File
@@ -6,10 +6,11 @@
*/ */
import type { import type {
AppBindings, AppDetail, AppGrant, AppHookRow, AppList, HookReceipts, AppSpec, AppSummary, AppVersionRow, BackfillPlan, BackfillResult, AppBindings, AppDetail, AppGrant, AppHookRow, AppList, HookReceipts, AppSpec, AppSummary, AppVersionRow, BackfillPlan, BackfillResult,
ComponentCatalog, Disposition, InstanceRow, LintIssue, PublishResult, ViewModel, ComponentCatalog, Disposition, InstanceRow, LintIssue, PublishResult, ViewModel, ViewShareCreated, ViewShareList,
ViewShareRow,
} from '../typesApp' } from '../typesApp'
import type { ConversationDetail, EvidenceSegmentDetail } from '../types' import type { ConversationDetail, EvidenceSegmentDetail } from '../types'
import { del, get, patch, post, put, qs, type RequestOptions } from './http' import { del, get, patch, post, put, qs, request, type RequestOptions } from './http'
const at = (id: string) => `/apps/${encodeURIComponent(id)}` const at = (id: string) => `/apps/${encodeURIComponent(id)}`
const hookAt = (id: string, name: string) => `${at(id)}/hooks/${encodeURIComponent(name)}` const hookAt = (id: string, name: string) => `${at(id)}/hooks/${encodeURIComponent(name)}`
@@ -74,6 +75,15 @@ export const apps = {
deleteHook: (id: string, name: string) => del(hookAt(id, name)), deleteHook: (id: string, name: string) => del(hookAt(id, name)),
/** 钩子最近收到的请求(manage):状态、原因、发起的实例或解决的待办,加近 1 小时验签失败几次(04 细案 A6) */ /** 钩子最近收到的请求(manage):状态、原因、发起的实例或解决的待办,加近 1 小时验签失败几次(04 细案 A6) */
hookReceipts: (id: string, name: string) => get<HookReceipts>(`${hookAt(id, name)}/receipts`), hookReceipts: (id: string, name: string) => get<HookReceipts>(`${hookAt(id, name)}/receipts`),
/**
* 分享链接(manage,服务端 api/shares.py):列、建、撤销。建要管理员开了「允许建分享链接」,到期必填(最长 max_days 天);
* key 只在建的这一次交回
*/
shares: (id: string) => get<ViewShareList>(`${at(id)}/shares`),
createShare: (id: string, body: { page_id: string; mode: 'latest' | 'fixed'; expires_at: string; embed_allowed: boolean }) =>
post<ViewShareCreated>(`${at(id)}/shares`, body),
revokeShare: (id: string, shareId: string) =>
request<ViewShareRow>(`${at(id)}/shares/${encodeURIComponent(shareId)}`, { method: 'DELETE' }),
/** /**
* 应用编辑器里助手的对话(D-e,manage):取这个应用的那段对话,没有就建(kind=app、归属这个应用)。带着历史轮次; * 应用编辑器里助手的对话(D-e,manage):取这个应用的那段对话,没有就建(kind=app、归属这个应用)。带着历史轮次;
* 老服务端只回对话本身时由调用方再按 id 取一次 * 老服务端只回对话本身时由调用方再按 id 取一次
+5 -3
View File
@@ -5,7 +5,7 @@
import type { CatalogReviewAction, CatalogSource, CatalogStatus } from '../types' import type { CatalogReviewAction, CatalogSource, CatalogStatus } from '../types'
import type { import type {
CapabilityEffect, CapabilityReviewOutcome, CapabilityReviewRow, CapabilityRow, EndpointRow, NotifyChannelRow, CapabilityEffect, CapabilityReviewOutcome, CapabilityReviewRow, CapabilityRow, EndpointRow, NotifyChannelRow,
NotifyEventRow, NotifyEventRow, NotifyMailState,
} from '../typesApp' } from '../typesApp'
import { del, get, patch, post, put, qs, type RequestOptions } from './http' import { del, get, patch, post, put, qs, type RequestOptions } from './http'
@@ -122,8 +122,10 @@ export const endpoints = {
} }
export const notify = { export const notify = {
channels: () => get<{ items: NotifyChannelRow[] }>('/notify/channels'), /** mail:邮件服务启没启用、本人的账号邮箱(老服务端不给) */
createChannel: (body: { name: string; scope: string; owner_id?: string | null; format: string; url: string; channels: () => get<{ items: NotifyChannelRow[]; mail?: NotifyMailState }>('/notify/channels'),
/** 邮件渠道:个人的不带 url(只发到账号邮箱),全局、应用的 url 是收件地址(逗号或换行分隔,最多 20 个) */
createChannel: (body: { name: string; scope: string; owner_id?: string | null; format: string; url?: string;
secret?: string | null; events: string[] }) => post<NotifyChannelRow>('/notify/channels', body), secret?: string | null; events: string[] }) => post<NotifyChannelRow>('/notify/channels', body),
updateChannel: (id: string, body: Record<string, any>) => patch<NotifyChannelRow>(`/notify/channels/${encodeURIComponent(id)}`, body), updateChannel: (id: string, body: Record<string, any>) => patch<NotifyChannelRow>(`/notify/channels/${encodeURIComponent(id)}`, body),
removeChannel: (id: string) => del(`/notify/channels/${encodeURIComponent(id)}`), removeChannel: (id: string) => del(`/notify/channels/${encodeURIComponent(id)}`),
+2
View File
@@ -689,6 +689,8 @@ export const api = {
/** 今天(本地日期)结论句裁判花了多少:设置页放在每日上限旁边。只读,计数只有裁判自己记 */ /** 今天(本地日期)结论句裁判花了多少:设置页放在每日上限旁边。只读,计数只有裁判自己记 */
judgeSpend: () => get<{ date?: string; usd?: number; calls?: number; unpriced_calls?: number judgeSpend: () => get<{ date?: string; usd?: number; calls?: number; unpriced_calls?: number
daily_max_usd?: number | null }>('/settings/judge/spend'), daily_max_usd?: number | null }>('/settings/judge/spend'),
/** 按存着的邮件服务设置发一封测试邮件,发到管理员自己的账号邮箱(管理员;关登录、没有邮箱时 422) */
mailTest: () => post<{ ok: boolean; message: string }>('/settings/mail/test'),
}, },
// ---- 工具 ---- // ---- 工具 ----
+2 -1
View File
@@ -104,7 +104,8 @@ export function actorHeader(): Record<string, string> {
function toLogin(path?: string) { function toLogin(path?: string) {
if (typeof window === 'undefined' || path === '/auth/login' || path === '/auth/me') return if (typeof window === 'undefined' || path === '/auth/login' || path === '/auth/me') return
const { pathname, search } = window.location const { pathname, search } = window.location
if (pathname === '/login') return // 分享页(/s)上没有登录这回事:不送去登录页
if (pathname === '/login' || pathname === '/s') return
const back = `${pathname}${search}` const back = `${pathname}${search}`
window.location.assign(back && back !== '/' ? `/login?next=${encodeURIComponent(back)}` : '/login') window.location.assign(back && back !== '/' ? `/login?next=${encodeURIComponent(back)}` : '/login')
} }
+42
View File
@@ -0,0 +1,42 @@
/**
* 分享页(/s)的两个接口:GET /api/shares/page、GET /api/shares/report.html(服务端 api/shares.py,04 细案 C2)。
*
* 分享 key 在链接的 # 后面(/s#<key>),打开页面时不发给服务器;这里读出来放进请求头 X-AgentLab-Share,服务端只认请求头。
* 不经 http.request:那条路会带署名头、把 401 当成「会话没了」整页跳去登录页——分享页上没有登录这回事,也不该在外壳的连接
* 状态里报来报去。报错照样翻成 ApiError({detail, code}),页面按 code 写「链接已撤销 / 已到期」。
*
* 只从 ./http、./bundle 导入(同 reportExport.ts,避免和 client.ts 循环导入)。
*/
import type { SharePayload } from '../typesApp'
import { ApiError, BASE, NETWORK_MESSAGE, codeOf, describeDetail } from './http'
import { dispositionName } from './bundle'
export const SHARE_HEADER = 'X-AgentLab-Share'
async function call(path: string, key: string): Promise<Response> {
let res: Response
try {
res = await fetch(BASE + path, { headers: { [SHARE_HEADER]: key }, credentials: 'omit', referrerPolicy: 'no-referrer' })
} catch (e: any) {
throw new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `${e?.name ?? 'Error'}: ${e?.message ?? e}` })
}
if (res.ok) return res
const text = await res.text().catch(() => '')
let body: any = null
try { body = JSON.parse(text) } catch { /* 不是 JSON(代理回的页面) */ }
const detail = body?.detail
throw new ApiError(res.status, detail ? describeDetail(detail, path) : `请求失败(HTTP ${res.status})`,
{ detail, code: codeOf(body), body, raw: `${res.status} ${text.slice(0, 500)}` })
}
export const shares = {
/** 分享页的视图模型、页脚、能下载报告的运行 */
page: async (key: string): Promise<SharePayload> => (await call('/shares/page', key)).json(),
/** 下载报告(附件):只认这条分享解析出来的运行;文件名取服务端给的,没给是 null */
report: async (key: string, runId: string, reportNode?: string): Promise<{ blob: Blob; name: string | null }> => {
const q = new URLSearchParams({ run: runId })
if (reportNode) q.set('report', reportNode)
const res = await call(`/shares/report.html?${q.toString()}`, key)
return { blob: await res.blob(), name: dispositionName(res.headers.get('Content-Disposition')) }
},
}
+10 -4
View File
@@ -11,6 +11,9 @@
* 隐藏(normal-user-apps N6):作者隐藏的组件,发布版解析时服务端就跳过;按草稿解析时照样下发、带 hidden。这里只看这个 * 隐藏(normal-user-apps N6):作者隐藏的组件,发布版解析时服务端就跳过;按草稿解析时照样下发、带 hidden。这里只看这个
* 标记:应用页上直接改的编辑状态(editing)画成半透明、标「已隐藏」,别处(编辑器的预览)照使用者看到的样子不画。 * 标记:应用页上直接改的编辑状态(editing)画成半透明、标「已隐藏」,别处(编辑器的预览)照使用者看到的样子不画。
* 编辑状态另给草稿里隐藏了的组件 id(editing.hidden):服务端没带标记的也认得出来。 * 编辑状态另给草稿里隐藏了的组件 id(editing.hidden):服务端没带标记的也认得出来。
*
* 分享页(pages/apps/SharePage,04 细案 C2)也用它:传 preview(不发起、不刷新)和 onEvidence——点数字不打开在线出处(那要
* 按运行读证据的身份,分享页上没有),交给分享页说明「出处在下载的报告里」。
*/ */
import { createContext, useCallback, useContext, useMemo, useState } from 'react' import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
@@ -35,6 +38,8 @@ export interface ViewRendererProps {
onReload?: () => void onReload?: () => void
/** 应用页上直接改的编辑状态:隐藏的组件画成半透明(不传时隐藏的组件不画) */ /** 应用页上直接改的编辑状态:隐藏的组件画成半透明(不传时隐藏的组件不画) */
editing?: ViewEditMarks editing?: ViewEditMarks
/** 点数字看出处时不开证据面板、交给调用方(分享页:出处在下载的报告里) */
onEvidence?: (evidence: ViewEvidence, label: string, text?: string) => void
} }
/** 编辑状态的标记:草稿里隐藏了的组件 id */ /** 编辑状态的标记:草稿里隐藏了的组件 id */
@@ -47,11 +52,12 @@ const EditMarksCtx = createContext<ViewEditMarks | null>(null)
/** 服务端按草稿解析时给隐藏的组件带的标记(ViewNode.hidden,发布版里没有这样的组件),加上这一次刚隐藏的 */ /** 服务端按草稿解析时给隐藏的组件带的标记(ViewNode.hidden,发布版里没有这样的组件),加上这一次刚隐藏的 */
const isHidden = (node: ViewNode, marks: ViewEditMarks | null) => node.hidden === true || !!marks?.hidden.has(node.id) const isHidden = (node: ViewNode, marks: ViewEditMarks | null) => node.hidden === true || !!marks?.hidden.has(node.id)
export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, onReload, editing }: ViewRendererProps) { export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, onReload, editing, onEvidence }: ViewRendererProps) {
const [evidence, setEvidence] = useState<EvidenceTarget | null>(null) const [evidence, setEvidence] = useState<EvidenceTarget | null>(null)
const openEvidence = useCallback((ev: ViewEvidence, label: string, text?: string) => { const openEvidence = useCallback((ev: ViewEvidence, label: string, text?: string) => {
setEvidence({ evidence: ev, label, text }) if (onEvidence) onEvidence(ev, label, text)
}, []) else setEvidence({ evidence: ev, label, text })
}, [onEvidence])
// 视图模型换了(刷新、重新取)就是新的一代:画坏了的组件跟着重试一次 // 视图模型换了(刷新、重新取)就是新的一代:画坏了的组件跟着重试一次
const gen = useMemo(() => String(++generation), [model]) const gen = useMemo(() => String(++generation), [model])
const render = useCallback((nodes: ViewNode[] | undefined): ReactNode => const render = useCallback((nodes: ViewNode[] | undefined): ReactNode =>
@@ -81,7 +87,7 @@ export function ViewRenderer({ model, appId, runId, preview = false, onNavigate,
)} )}
<EditMarksCtx.Provider value={editing ?? null}>{render(model.tree)}</EditMarksCtx.Provider> <EditMarksCtx.Provider value={editing ?? null}>{render(model.tree)}</EditMarksCtx.Provider>
</div> </div>
<EvidenceDrawer target={evidence} onClose={() => setEvidence(null)} /> {!onEvidence && <EvidenceDrawer target={evidence} onClose={() => setEvidence(null)} />}
</ViewCtx.Provider> </ViewCtx.Provider>
) )
} }
+5 -1
View File
@@ -5611,7 +5611,8 @@ export const NOTIFY_TEXT = {
urlHint: '地址加密保存,之后只显示主机名', urlHint: '地址加密保存,之后只显示主机名',
secret: '签名密钥(可选)', secret: '签名密钥(可选)',
format: '格式', format: '格式',
formats: { generic: '通用 JSON', feishu: '飞书群机器人', wecom: '企业微信群机器人', dingtalk: '钉钉群机器人' } as Record<string, string>, // email:邮件(第五种格式,服务端 notify/mail.py):要先在「邮件服务」一节配好并启用
formats: { generic: '通用 JSON', feishu: '飞书群机器人', wecom: '企业微信群机器人', dingtalk: '钉钉群机器人', email: '邮件' } as Record<string, string>,
scope: '范围', scope: '范围',
scopes: { global: '全局', app: '应用', user: '个人' } as Record<string, string>, scopes: { global: '全局', app: '应用', user: '个人' } as Record<string, string>,
events: '订阅的事件', events: '订阅的事件',
@@ -6085,6 +6086,9 @@ export const APP_SETTING_TEXT = {
onDemandLimitInvalid: '请填写 0 到 100,000 之间的整数', onDemandLimitInvalid: '请填写 0 到 100,000 之间的整数',
personalApps: '使用者可以建个人应用', personalApps: '使用者可以建个人应用',
personalAppsHint: '开启后,使用者可以从说需求、场景模板或问数据的回答建一个只有自己看得见的应用:只读、结果遮罩,每人每天的刷新次数按上面的每日上限算;想给别人用要提交给搭建者审核发布。搭建者及以上不受这一项限制', personalAppsHint: '开启后,使用者可以从说需求、场景模板或问数据的回答建一个只有自己看得见的应用:只读、结果遮罩,每人每天的刷新次数按上面的每日上限算;想给别人用要提交给搭建者审核发布。搭建者及以上不受这一项限制',
// 分享链接(服务端 apps.share_links,04 细案 C2):默认关
shareLinks: '允许建分享链接',
shareLinksHint: '开启后,管理应用的人可以在应用编辑的「岗位与授权」里给一页建分享链接:拿到链接的人不用登录就能看(只读、只显示定时出数的视图、数据和使用者看到的一样遮过),必须设到期、最长 90 天、随时可以撤销。关掉时已有的分享链接一并打不开',
} }
/** /**
+119
View File
@@ -802,3 +802,122 @@ export const HOOK_RECEIPT_TEXT = {
? `近 1 小时验签失败 ${n} 次:验签失败的请求不留记录,只计数` ? `近 1 小时验签失败 ${n} 次:验签失败的请求不留记录,只计数`
: '近 1 小时没有验签失败的请求'), : '近 1 小时没有验签失败的请求'),
} }
/**
* 邮件(通知渠道的第五种格式,服务端 notify/mail.py,04 细案 B2):设置 → 通知里的「邮件服务」「每天的待办汇总」两节、
* 渠道里的邮件格式。照实说:成功只写「已交给邮件服务器」,退信不追踪,不说已送达
*/
export const MAIL_TEXT = {
section: '邮件服务',
hint: '通知渠道选「邮件」时用这台邮件服务器发信。口令加密保存,之后只显示「已设置」',
readOnly: '只有管理员能修改邮件服务',
enabled: '启用邮件',
enabledHint: '关着时通知渠道里没有「邮件」这一格式;已有的邮件渠道发不出去,投递记录里写明原因',
host: 'SMTP 服务器',
hostHint: '主机名或 IP 地址,例如 smtp.example.com,不带协议和端口',
port: '端口',
security: '加密方式',
securityLabel: { starttls: 'STARTTLS(常用端口 587)', ssl: 'SSL/TLS(常用端口 465)', none: '不加密(只在内网中继时用)' } as Record<string, string>,
username: '用户名',
usernameHint: '服务器不要求登录时留空',
password: '口令',
passwordSet: '已设置;留空表示不修改',
passwordUnset: '未设置',
passwordClear: '清除已保存的口令',
fromAddr: '发件地址',
fromName: '发件人名称',
defaultAssignees: '默认发邮件给待办处理人',
defaultAssigneesHint: '打开后,第一次有待办派给某人时,自动给他建一个个人邮件渠道(订阅待办打开、动作结果未知、审核有结果),发到他的账号邮箱;本人之后可以改订阅或关掉、删掉。缺省关:刚配好邮件服务时不会突然往外发邮件',
save: '保存邮件服务',
saved: '已保存邮件服务设置',
invalidPort: '请填写 1 到 65535 之间的整数',
leaveBody: '邮件服务有没保存的改动,离开将丢失这些改动。',
test: '发测试邮件',
testHint: '按已保存的设置发一封到你自己的账号邮箱',
testDirty: '先保存再发测试邮件',
handoff: '已交给邮件服务器',
handoffHint: '「已交给邮件服务器」不等于已经进了收件箱:退信不追踪,是否送达以邮件服务器为准',
// 每天的待办汇总(服务端 notify/digest.py)
digest: '每天的待办汇总',
digestHint: '每天这个时间(按服务端所在时区)给订阅了「每天的待办汇总」的个人渠道发一条:还有几条待办、最早的时限、最多 5 条和去待办箱的链接。没有待办的人那天不发',
digestAt: '发送时间',
digestSave: '保存发送时间',
digestSaved: (at: string) => `每天 ${at} 发待办汇总`,
digestReadOnly: '只有管理员能修改发送时间',
// 渠道里的邮件格式
personalTo: (email: string | null) => (email ? `发到账号邮箱 ${email}` : '你的账号没有邮箱地址,发不出去:请联系管理员在「设置 → 身份」里填写'),
personalToHint: '个人邮件渠道只发到你的账号邮箱,不能填别的地址',
recipients: '收件地址',
recipientsHint: '多个地址用逗号或换行分隔,最多 20 个;人多的请填邮件组的地址',
to: (list: string[]) => (list.length
? `发到 ${list.slice(0, 2).join('、')}${list.length > 2 ? ` 等 ${list.length} 个地址` : ''}` : ''),
off: '邮件服务没有启用,暂时不能选邮件:请管理员在下面的「邮件服务」里配置并启用',
}
/**
* 分享链接(服务端 api/shares.py,04 细案 C2):应用编辑 → 岗位与授权里的管理。叫「分享链接」,不叫快照的那种说法:
* 「快照」专指查询快照
*/
export const SHARE_TEXT = {
section: '分享链接',
hint: '拿到链接的人不用登录就能看这一页:只读,只显示定时出数的视图,看到的数据和使用者一样遮过;出处给「下载报告」。必须设到期,随时可以撤销',
off: '管理员还没有打开分享链接:在「设置 → 偏好设置」的「应用」一节里打开「允许建分享链接」后才能建',
offWithRows: '管理员已关闭分享链接:下面这些暂时打不开,仍然可以撤销',
personal: '个人应用只给你自己用,不能分享',
unpublished: '发布之后才能建分享链接',
create: '新建分享链接',
empty: '还没有分享链接',
page: '分享哪一页',
pageHint: '只显示这一页上定时出数的视图;入口、表单、实例、待办和报告正文在分享页上换成说明',
mode: '数据',
modeLatest: '始终最新:跟着最近一次封存走',
modeFixed: '固定在现在这一期:之后出的数不换上来',
modeShort: { latest: '始终最新', fixed: '固定这一期' } as Record<string, string>,
expires: '有效期',
expiresDays: (n: number) => `${n} 天`,
expiresHint: (max: number) => `必须设到期,最长 ${max} 天`,
embed: '允许嵌入到别的站点',
embedHint: '勾上之后这条链接能放进门户的 iframe(只限部署配置里允许嵌入分享页的站点);不勾的在别的页面里不显示内容',
embedNoOrigins: '部署没有配置允许嵌入分享页的站点(AGENTLAB_EMBED_ORIGINS),勾了也只能在 AgentLab 自己的页面里嵌',
submit: '建分享链接',
created: '分享链接已建好',
once: '链接只显示这一次,请现在复制下来;之后只看得到开头几位',
dismiss: '我已复制',
state: { active: '有效', revoked: '已撤销', expired: '已到期' } as Record<string, string>,
embedded: '可嵌入',
opened: (n: number) => (n ? `打开过 ${n} 次` : '还没打开过'),
by: (who: string) => `由 ${who} 建`,
until: (at: string) => `${at} 到期`,
revoke: '撤销',
revokeTitle: (prefix: string) => `撤销分享链接 ${prefix}…?`,
revokeBody: '撤销之后,拿着这条链接的人打开会看到「链接已撤销」,不能恢复;要再分享请建一条新的。',
revoked: '已撤销分享链接',
}
/** 分享页(/s,不在外壳里):拿到分享链接的人看到的页面 */
export const SHARE_PAGE_TEXT = {
loading: '正在打开分享的页面…',
readOnly: '只读',
footer: (who: string, at: string, until: string) => `只读 · 由 ${who} 于 ${at} 分享 · ${until} 到期`,
someone: '管理应用的人',
fixed: '固定在分享时的那一期',
latest: '跟着最近一次封存更新',
stamps: '数据的来历',
stampsHint: '每个视图取的是哪一次运行、截至什么时候、按哪个口径、出具档位',
download: '下载报告',
downloadHint: '下载的报告里每个数都点得开出处(口径、来源、计算方式),离线也能看',
downloading: '正在下载…',
downloaded: '已下载报告',
noReports: '这一页的数据没有可下载的报告',
evidenceTitle: '出处在下载的报告里',
evidence: '分享页不打开在线出处:点下面的「下载报告」,下载的报告里每个数都点得开出处,离线也能看',
close: '知道了',
noKey: '链接不完整:缺少 # 后面的那一段,请向分享的人确认链接',
errorTitle: {
share_revoked: '链接已撤销', share_expired: '链接已到期', share_links_off: '分享链接已关闭',
share_gone: '分享的页面打不开了', share_not_found: '链接无效', share_key_missing: '链接不完整',
} as Record<string, string>,
failed: '分享的页面打不开',
framed: '这条分享链接没有允许嵌入到别的页面,请直接打开链接查看',
openDirect: '在新窗口打开',
}
+18 -2
View File
@@ -682,6 +682,8 @@ function PrefsTab() {
// 应用一组(D-j):按需视图每个应用每天最多发起几次;使用者能不能建个人应用(N7,默认关) // 应用一组(D-j):按需视图每个应用每天最多发起几次;使用者能不能建个人应用(N7,默认关)
appsDaily: string appsDaily: string
appsPersonal: boolean appsPersonal: boolean
// 允许建分享链接(服务端 apps.share_links,04 细案 C2,默认关)
appsShare: boolean
} | null>(null) } | null>(null)
const [spend, setSpend] = useState<{ usd?: number; calls?: number; unpriced_calls?: number } | null>(null) const [spend, setSpend] = useState<{ usd?: number; calls?: number; unpriced_calls?: number } | null>(null)
// 核查一组(双盲复算、反方审稿、审批质询)的草稿另放一份,和上面的共用保存条;verifySaves 是存过几次,今日核查花费跟着再取 // 核查一组(双盲复算、反方审稿、审批质询)的草稿另放一份,和上面的共用保存条;verifySaves 是存过几次,今日核查花费跟着再取
@@ -735,6 +737,7 @@ function PrefsTab() {
// 服务端没给这一项时按默认的 200 显示(同服务端的缺省值) // 服务端没给这一项时按默认的 200 显示(同服务端的缺省值)
appsDaily: String(values?.apps?.on_demand_daily_limit ?? APPS_DAILY_DEFAULT), appsDaily: String(values?.apps?.on_demand_daily_limit ?? APPS_DAILY_DEFAULT),
appsPersonal: values?.apps?.user_personal_apps === true, appsPersonal: values?.apps?.user_personal_apps === true,
appsShare: values?.apps?.share_links === true,
} }
const cur = draft ?? saved const cur = draft ?? saved
const stepCap = Number(values?.limits?.max_agent_steps) || 100 const stepCap = Number(values?.limits?.max_agent_steps) || 100
@@ -834,7 +837,8 @@ function PrefsTab() {
const catalogDraft = draftChanged ? { provider: cur.draftProvider || null, model: cur.draftModel.trim() || null } : null const catalogDraft = draftChanged ? { provider: cur.draftProvider || null, model: cur.draftModel.trim() || null } : null
// 应用一组同样整组写:别的键(以后加的)照原样带回去 // 应用一组同样整组写:别的键(以后加的)照原样带回去
const apps = appsChanged const apps = appsChanged
? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()), user_personal_apps: cur.appsPersonal } ? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()), user_personal_apps: cur.appsPersonal,
share_links: cur.appsShare }
: null : null
if (runChanged || judge || verify || catalogDraft || apps) { if (runChanged || judge || verify || catalogDraft || apps) {
const run = { const run = {
@@ -1201,6 +1205,17 @@ function PrefsTab() {
</span> </span>
</span> </span>
</label> </label>
<label className="mt-3 flex items-start gap-2 text-xs">
<input type="checkbox" className="mt-0.5" checked={cur.appsShare} data-apps-share-links=""
aria-describedby="pref-apps-share-hint"
onChange={(e) => edit({ appsShare: e.target.checked })} />
<span>
{APP_SETTING_TEXT.shareLinks}
<span id="pref-apps-share-hint" className="mt-0.5 block text-2xs leading-relaxed text-faint">
{APP_SETTING_TEXT.shareLinksHint}
</span>
</span>
</label>
</section> </section>
</fieldset> </fieldset>
</> </>
@@ -1228,6 +1243,7 @@ function PrefsTab() {
jClick: '每次点击的金额上限', jDaily: '每日金额上限', jClick: '每次点击的金额上限', jDaily: '每日金额上限',
draftProvider: DRAFT_MODEL_SETTING_TEXT.provider, draftModel: DRAFT_MODEL_SETTING_TEXT.model, draftProvider: DRAFT_MODEL_SETTING_TEXT.provider, draftModel: DRAFT_MODEL_SETTING_TEXT.model,
appsDaily: APP_SETTING_TEXT.onDemandLimit, appsPersonal: APP_SETTING_TEXT.personalApps, appsDaily: APP_SETTING_TEXT.onDemandLimit, appsPersonal: APP_SETTING_TEXT.personalApps,
appsShare: APP_SETTING_TEXT.shareLinks,
})[k]).concat(verifyLabels(verifyKeys)).join('、')} })[k]).concat(verifyLabels(verifyKeys)).join('、')}
</span> </span>
</span> </span>
@@ -1261,7 +1277,7 @@ function PrefsTab() {
} }
/** 应用一组在草稿里的几项:保存时整组写 apps */ /** 应用一组在草稿里的几项:保存时整组写 apps */
const APPS_DRAFT = new Set(['appsDaily', 'appsPersonal']) const APPS_DRAFT = new Set(['appsDaily', 'appsPersonal', 'appsShare'])
/** 按需视图每日上限的缺省值和最大值(同服务端 settings.APPS_DEFAULTS、APPS_DAILY_LIMIT_MAX) */ /** 按需视图每日上限的缺省值和最大值(同服务端 settings.APPS_DEFAULTS、APPS_DAILY_LIMIT_MAX) */
const APPS_DAILY_DEFAULT = 200 const APPS_DAILY_DEFAULT = 200
+178
View File
@@ -0,0 +1,178 @@
/**
* 分享页(/s#<分享 key>,不在外壳里,同登录页的写法;04 细案 C2):拿到分享链接的人不用登录就能看应用的一页。
*
* - key 在 # 后面:打开页面时不发给服务器、不进访问日志和 Referer,这里读出来放进请求头(api/shares.ts);
* - 用同一个 ViewRenderer 渲染服务端按分享解析好的视图模型(不另写渲染器):preview 打开,所有操作禁用;入口、表单、实例、
* 待办、报告正文服务端已经换成写明原因的说明块;
* - 印章常显:页面上没放印章组件的视图,也在「数据的来历」里逐个列出它的印章(截至、口径、出具档位、来源);
* - 出处给「下载报告」:点数字不开在线证据,说明出处在下载的报告里(报告文件自己离线点得开);
* - 页脚写「只读 · 由 X 于 … 分享 · … 到期」;撤销、到期、关了开关、链接不完整,照服务端的话写明,不给空白页;
* - 这条分享没允许嵌入、页面又在别的页面的 iframe 里:不显示内容,给「在新窗口打开」(服务端在 /s 上只能按全站的嵌入白名单
* 发头,打开页面那一刻它还不知道是哪一条分享)。
*
* 不认人、不取目录、不连心跳(App 在 /s 上整个跳过外壳的启动),也就不会因为没登录被送去登录页。
*/
import { useEffect, useMemo, useState } from 'react'
import { Download, ExternalLink, Lock } from 'lucide-react'
import { ApiError } from '../../api/http'
import { shares } from '../../api/shares'
import { ViewRenderer } from '../../apps/ViewRenderer'
import { Stamp } from '../../apps/Stamp'
import { Logo } from '../../components/Logo'
import { Modal, Notice, Spinner, toast } from '../../components/ui'
import { formatDateTime } from '../../lib/format'
import { SHARE_PAGE_TEXT as T } from '../../lib/termsApp'
import type { SharePayload } from '../../typesApp'
/** 链接里 # 后面的那一段就是分享 key */
export const shareKeyOf = (hash: string): string => decodeURIComponent(hash.replace(/^#/, '').trim())
const framed = (): boolean => {
try {
return window.self !== window.top
} catch {
// 跨源的父页面读 top 会抛:那就是被别的站点嵌着
return true
}
}
export function SharePage() {
const key = useMemo(() => shareKeyOf(window.location.hash), [])
const [data, setData] = useState<SharePayload | null>(null)
const [error, setError] = useState<{ code?: string; message: string } | null>(key ? null : { code: 'share_key_missing', message: T.noKey })
const [note, setNote] = useState(false)
useEffect(() => {
if (!key) return
shares.page(key).then(setData, (e) => setError({
code: e instanceof ApiError ? e.code : undefined,
message: e instanceof Error ? e.message : T.failed,
}))
}, [key])
useEffect(() => {
if (data) document.title = `${data.view.app.name} · ${data.view.page.title || data.view.page.id}`
}, [data])
const blocked = !!data && !data.share.embed_allowed && framed()
const state = error ? 'error' : !data ? 'loading' : blocked ? 'framed' : 'ok'
return (
<div className="h-full overflow-auto bg-bg" data-share-page={state}>
<div className="mx-auto max-w-5xl space-y-4 p-4 sm:p-6">
<header className="flex flex-wrap items-center gap-3">
<Logo size={26} className="text-accent" title="AgentLab" />
<div className="min-w-0 flex-1">
<h1 className="truncate text-base font-semibold text-fg">
{data ? data.view.app.name : 'AgentLab'}
{data?.view.page.title && <span className="font-normal text-dim"> · {data.view.page.title}</span>}
</h1>
</div>
{data && (
<span className="chip flex items-center gap-1" data-share-readonly="">
<Lock size={10} aria-hidden /> {T.readOnly} · {data.share.mode === 'fixed' ? T.fixed : T.latest}
</span>
)}
</header>
{error ? (
<div className="rounded-lg border bg-panel p-4" data-share-error={error.code ?? ''}>
<h2 className="text-sm font-semibold">{(error.code && T.errorTitle[error.code]) || T.failed}</h2>
<p className="mt-1 text-xs leading-relaxed text-dim" role="alert">{error.message}</p>
</div>
) : !data ? (
<div className="flex items-center gap-2 text-xs text-dim" role="status"><Spinner size={12} /> {T.loading}</div>
) : blocked ? (
<div className="rounded-lg border bg-panel p-4" data-share-framed="">
<p className="text-xs leading-relaxed text-fg">{T.framed}</p>
<a className="btn btn-sm mt-2" href={window.location.href} target="_blank" rel="noopener noreferrer">
<ExternalLink size={11} aria-hidden /> {T.openDirect}
</a>
</div>
) : (
<>
<ViewRenderer model={data.view} appId={data.view.app.id} preview onEvidence={() => setNote(true)} />
<Stamps data={data} />
<footer className="space-y-2 border-t pt-3" data-share-footer="">
<Downloads shareKey={key} data={data} />
<p className="text-2xs text-faint" data-share-footer-text="">
{T.footer(data.share.created_by || T.someone, formatDateTime(data.share.created_at),
formatDateTime(data.share.expires_at))}
</p>
</footer>
</>
)}
</div>
{data && (
<Modal open={note} onClose={() => setNote(false)} title={T.evidenceTitle} width={460}
footer={<button type="button" className="btn" onClick={() => setNote(false)}>{T.close}</button>}>
<div className="space-y-3" data-share-evidence-note="">
<p className="text-xs leading-relaxed text-fg">{T.evidence}</p>
<Downloads shareKey={key} data={data} />
</div>
</Modal>
)}
</div>
)
}
/** 印章常显:每个视图来源的印章(取到了运行的),页面上有没有放印章组件都列 */
function Stamps({ data }: { data: SharePayload }) {
const rows = Object.entries(data.view.sources).filter(([, s]) => s.stamp)
if (!rows.length) return null
return (
<section className="space-y-2" data-share-stamps={rows.length}>
<div>
<h2 className="text-xs font-semibold">{T.stamps}</h2>
<p className="text-2xs text-faint">{T.stampsHint}</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{rows.map(([vid, s]) => <Stamp key={vid} stamp={s.stamp!} />)}
</div>
</section>
)
}
/** 下载报告:只给这条分享解析出来、带报告的那几次运行(服务端只认这几次) */
function Downloads({ shareKey, data }: { shareKey: string; data: SharePayload }) {
const [busy, setBusy] = useState<string | null>(null)
const items = data.downloads.flatMap((d) => d.reports.map((node) => ({ ...d, node })))
if (!items.length) return <p className="text-2xs text-faint" data-share-no-reports="">{T.noReports}</p>
const download = async (runId: string, node: string) => {
const id = `${runId}:${node}`
setBusy(id)
try {
const { blob, name } = await shares.report(shareKey, runId, node)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = name ?? `报告-${runId.slice(0, 6)}.html`
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
toast.ok(T.downloaded)
} catch (e) {
toast.error(e)
} finally {
setBusy(null)
}
}
return (
<div className="space-y-1">
<div className="flex flex-wrap gap-2">
{items.map((d) => {
const id = `${d.run_id}:${d.node}`
return (
<button key={id} type="button" className="btn btn-sm" disabled={busy != null} data-share-download={d.run_id}
onClick={() => void download(d.run_id, d.node)}>
{busy === id ? <Spinner size={11} /> : <Download size={11} aria-hidden />}
{busy === id ? T.downloading : T.download}
{items.length > 1 && d.as_of_text && <span className="text-2xs text-faint">({d.as_of_text})</span>}
</button>
)
})}
</div>
<Notice tone="info" attr={{ 'data-share-download-hint': '' }}>{T.downloadHint}</Notice>
</div>
)
}
+169 -3
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { KeyRound, Plus } from 'lucide-react' import { KeyRound, Link2, Plus } from 'lucide-react'
import { api } from '../../../api/client' import { api } from '../../../api/client'
import type { AppHookRow, HookReceipts } from '../../../typesApp' import type { AppHookRow, HookReceipts, ViewShareCreated, ViewShareList, ViewShareRow } from '../../../typesApp'
import { RecentRequests, type RecentRequestRow } from '../../../components/RecentRequests' import { RecentRequests, type RecentRequestRow } from '../../../components/RecentRequests'
import { HOOK_RECEIPT_TEXT, RECENT_REQUESTS_TEXT } from '../../../lib/termsApp' import { formatDateTime } from '../../../lib/format'
import { HOOK_RECEIPT_TEXT, RECENT_REQUESTS_TEXT, SHARE_TEXT as S } from '../../../lib/termsApp'
import { import {
CopyButton, DeleteButton, EmptyState, ErrorState, Field, SectionBar, Skeleton, Spinner, confirmDialog, toast, CopyButton, DeleteButton, EmptyState, ErrorState, Field, SectionBar, Skeleton, Spinner, confirmDialog, toast,
} from '../../../components/ui' } from '../../../components/ui'
@@ -20,6 +21,8 @@ import { useEditor } from './context'
* 岗位:图里人工节点写的岗位名(assignee 的岗位)在这里绑定到人或组;绑定不进 spec,存在草稿的 bindings 里,发布时 * 岗位:图里人工节点写的岗位名(assignee 的岗位)在这里绑定到人或组;绑定不进 spec,存在草稿的 bindings 里,发布时
* 进应用版本——spec 可以导出,绑定是这一个部署自己的。人员变动只改组成员,不发新版本。 * 进应用版本——spec 可以导出,绑定是这一个部署自己的。人员变动只改组成员,不发新版本。
* 授权:use / observe / manage,即时生效、写审计,不需要发新版本(A1)。关登录时所有人都是管理员,授权开登录后才生效 * 授权:use / observe / manage,即时生效、写审计,不需要发新版本(A1)。关登录时所有人都是管理员,授权开登录后才生效
* 分享链接(04 细案 C2):管理员开了「允许建分享链接」后,管理应用的人给发布版的一页建只读、不用登录的链接(必设到期、
* 最长 90 天、可撤销);链接只在建好时显示一次。关着时不给新建,已有的照列、能撤销
*/ */
const ROLES: GrantRole[] = ['use', 'observe', 'manage'] const ROLES: GrantRole[] = ['use', 'observe', 'manage']
@@ -73,6 +76,169 @@ export function AccessSection() {
</div> </div>
</section> </section>
<Grants /> <Grants />
<Shares />
</div>
)
}
/** 有效期的几个选项(天),按服务端的上限截 */
const SHARE_DAYS = [1, 7, 30, 90]
function Shares() {
const { appId, detail } = useEditor()
const personal = detail.scope === 'personal'
const pages = detail.published?.pages ?? []
const [list, setList] = useState<ViewShareList | null>(null)
const [error, setError] = useState<unknown>(null)
const [form, setForm] = useState<{ page: string; mode: 'latest' | 'fixed'; days: number; embed: boolean } | null>(null)
const [busy, setBusy] = useState(false)
const [made, setMade] = useState<ViewShareCreated | null>(null)
const load = useCallback(() => {
if (personal) return
api.apps.shares(appId).then((v) => { setList(v); setError(null) }, setError)
}, [appId, personal])
useEffect(load, [load])
if (personal) {
return (
<section data-app-shares="personal">
<SectionBar title={S.section} hint={S.personal} />
</section>
)
}
const create = async () => {
if (!form || busy) return
setBusy(true)
try {
const out = await api.apps.createShare(appId, {
page_id: form.page, mode: form.mode, embed_allowed: form.embed,
expires_at: new Date(Date.now() + form.days * 86_400_000).toISOString(),
})
setMade(out)
setForm(null)
load()
} catch (e) {
toast.error(e)
} finally {
setBusy(false)
}
}
const revoke = async (row: ViewShareRow) => {
if (!(await confirmDialog({ title: S.revokeTitle(row.prefix), body: S.revokeBody, danger: true, confirmLabel: S.revoke }))) return
try {
await api.apps.revokeShare(appId, row.id)
toast.ok(S.revoked)
load()
} catch (e) {
toast.error(e)
}
}
const maxDays = list?.max_days ?? 90
const canCreate = !!list?.enabled && pages.length > 0 && detail.published_version != null
const hint = !list ? S.hint : !list.enabled ? (list.items.length ? S.offWithRows : S.off)
: detail.published_version == null ? S.unpublished : S.hint
return (
<section data-app-shares={list ? (list.enabled ? 'on' : 'off') : ''}>
<SectionBar title={S.section} hint={hint}>
{canCreate && !form && (
<button type="button" className="btn btn-sm" data-app-share-create=""
onClick={() => { setMade(null); setForm({ page: pages[0].id, mode: 'latest', days: Math.min(30, maxDays), embed: false }) }}>
<Plus size={11} aria-hidden /> {S.create}
</button>
)}
</SectionBar>
{made && <ShareOnce made={made} onDismiss={() => setMade(null)} />}
{form && (
<div className="mb-3 space-y-3 rounded-lg border bg-panel px-3 py-3" data-app-share-form="">
<Field label={S.page} hint={S.pageHint}>
{(p) => (
<select {...p} className="field max-w-72" value={form.page} data-app-share-page=""
onChange={(e) => setForm({ ...form, page: e.target.value })}>
{pages.map((pg) => <option key={pg.id} value={pg.id}>{pg.title || pg.id}</option>)}
</select>
)}
</Field>
<fieldset>
<legend className="label">{S.mode}</legend>
{(['latest', 'fixed'] as const).map((m) => (
<label key={m} className="flex items-center gap-1.5 text-xs">
<input type="radio" name="share-mode" checked={form.mode === m} data-app-share-mode={m}
onChange={() => setForm({ ...form, mode: m })} />
{m === 'latest' ? S.modeLatest : S.modeFixed}
</label>
))}
</fieldset>
<Field label={S.expires} hint={S.expiresHint(maxDays)} required>
{(p) => (
<select {...p} className="field w-32" value={form.days} data-app-share-days=""
onChange={(e) => setForm({ ...form, days: Number(e.target.value) })}>
{SHARE_DAYS.filter((d) => d <= maxDays).map((d) => <option key={d} value={d}>{S.expiresDays(d)}</option>)}
</select>
)}
</Field>
<label className="flex items-start gap-2 text-xs">
<input type="checkbox" className="mt-0.5" checked={form.embed} data-app-share-embed=""
onChange={(e) => setForm({ ...form, embed: e.target.checked })} />
<span>
{S.embed}
<span className="mt-0.5 block text-2xs leading-relaxed text-faint">{list?.embed_origins ? S.embedHint : S.embedNoOrigins}</span>
</span>
</label>
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>取消</button>
<button type="button" className="btn btn-sm btn-primary" disabled={busy} onClick={() => void create()} data-app-share-submit="">
{busy ? <Spinner size={11} /> : <Link2 size={11} aria-hidden />} {S.submit}
</button>
</div>
</div>
)}
{error ? <ErrorState error={error} onRetry={load} /> : !list ? <Skeleton rows={2} height={36} /> : !list.items.length ? (
<p className="text-2xs text-faint" data-app-shares-empty="">{S.empty}</p>
) : (
<div className="space-y-1.5">
{list.items.map((row) => (
<div key={row.id} className="flex flex-wrap items-center gap-2 rounded-lg border bg-panel px-3 py-2 text-xs"
data-app-share={row.prefix} data-app-share-state={row.state}>
<span className="mono">{row.prefix}…</span>
<span className="font-medium">{row.page_title}</span>
<span className="chip">{S.modeShort[row.mode] ?? row.mode}</span>
<span className="chip" style={row.state === 'active' ? { color: 'var(--ok)' } : { color: 'var(--st-waiting)' }}>
{S.state[row.state] ?? row.state}
</span>
{row.embed_allowed && <span className="chip">{S.embedded}</span>}
<span className="min-w-0 flex-1 truncate text-2xs text-faint">
{[row.expires_at ? S.until(formatDateTime(row.expires_at)) : null, S.opened(row.open_count),
row.created_by_name || row.created_by ? S.by(row.created_by_name || row.created_by || '') : null]
.filter(Boolean).join(' · ')}
</span>
{row.state === 'active' && (
<button type="button" className="btn btn-xs" onClick={() => void revoke(row)} data-app-share-revoke={row.prefix}>
{S.revoke}
</button>
)}
</div>
))}
</div>
)}
</section>
)
}
/** 刚建好的分享链接:只显示这一次(库里只有哈希),附复制按钮;关掉就没有了 */
function ShareOnce({ made, onDismiss }: { made: ViewShareCreated; onDismiss: () => void }) {
const link = made.url ?? `${window.location.origin}${made.path}`
return (
<div className="mb-3 space-y-2 rounded-lg border px-3 py-2.5" style={{ borderColor: 'var(--st-waiting)' }} role="status"
data-app-share-once="">
<div className="flex items-center gap-1.5 text-xs font-semibold"><Link2 size={12} aria-hidden /> {S.created}</div>
<p className="text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }}>{S.once}</p>
<div className="flex items-center gap-2">
<code className="mono min-w-0 flex-1 break-all rounded border bg-bg px-2 py-1 text-2xs" data-app-share-link="">{link}</code>
<CopyButton text={link} />
</div>
<div className="flex justify-end">
<button type="button" className="btn btn-sm" onClick={onDismiss} data-app-share-dismiss="">{S.dismiss}</button>
</div>
</div> </div>
) )
} }
+285 -31
View File
@@ -1,37 +1,55 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Bell, Plus, Send } from 'lucide-react' import { Bell, Check, Mail, Plus, Send } from 'lucide-react'
import { api } from '../../api/client' import { api } from '../../api/client'
import { import {
DeleteButton, EmptyState, ErrorState, Field, Modal, SectionBar, Skeleton, Spinner, confirmDialog, toast, DeleteButton, EmptyState, ErrorState, Field, Modal, SectionBar, Skeleton, Spinner, confirmDialog, toast,
} from '../../components/ui' } from '../../components/ui'
import { formatDateTime } from '../../lib/format' import { formatDateTime } from '../../lib/format'
import { useLeaveGuard } from '../../lib/leave'
import { NOTIFY_TEXT as T, SETTINGS_ROLE_TEXT } from '../../lib/terms' import { NOTIFY_TEXT as T, SETTINGS_ROLE_TEXT } from '../../lib/terms'
import { NOTIFY_PERSONAL_EVENT_LABEL } from '../../lib/termsApp' import { MAIL_TEXT as M, NOTIFY_PERSONAL_EVENT_LABEL } from '../../lib/termsApp'
import { useAtLeast } from '../../store/auth' import { useAtLeast } from '../../store/auth'
import type { NotifyChannelRow, NotifyEventRow } from '../../typesApp' import type { NotifyChannelRow, NotifyEventRow, NotifyMailState } from '../../typesApp'
/* /*
* 设置 → 通知(A4 第 7 条):待办、动作的事件推送到哪些地址。格式只有 Webhook 的四种(Q6),发送核心和定时任务的 * 设置 → 通知(A4 第 7 条):待办、动作的事件推送到哪些地址。格式是 Webhook 的四种加上邮件(04 细案 B2,改写了 Q6):
* 通知共用;消息里只放标题、状态和链接,不放报告里的数字。地址和签名密钥加密保存,之后只显示主机名。 * Webhook 的发送核心和定时任务的通知共用;邮件走管理员在「邮件服务」一节配的 SMTP。消息里只放标题、状态和链接,不放报告
* 里的数字。地址和签名密钥加密保存,之后只显示主机名。
* 全局渠道只给管理员建;个人渠道每个人都能建(§3.12)。开了登录的使用者只看、只建自己的个人渠道(scope=user), * 全局渠道只给管理员建;个人渠道每个人都能建(§3.12)。开了登录的使用者只看、只建自己的个人渠道(scope=user),
* 投递记录只给搭建者以上 * 投递记录只给搭建者以上。
*
* 邮件:邮件服务没启用时不列「邮件」这一格式(建了也发不出去);个人邮件渠道不填地址,只发到本人的账号邮箱(列表里写
* 「发到账号邮箱 …」,没有邮箱照实写发不出去);全局、应用的填固定收件地址。投递成功写「已交给邮件服务器」,不写已送达:
* 退信不追踪。「邮件服务」「每天的待办汇总」两节给搭建者以上看(设置接口搭建者只读),只有管理员能改。
*/ */
const FORMATS = ['generic', 'feishu', 'wecom', 'dingtalk'] as const const WEBHOOK_FORMATS = ['generic', 'feishu', 'wecom', 'dingtalk'] as const
const EVENTS = Object.keys(T.eventLabel) const EVENTS = Object.keys(T.eventLabel)
/** 事件在这个范围的渠道上怎么叫:个人渠道的实例结束只推自己发起的,叫「我发起的实例完成 / 失败」 */ /** 事件在这个范围的渠道上怎么叫:个人渠道的实例结束只推自己发起的,叫「我发起的实例完成 / 失败」 */
const eventLabel = (event: string, scope: string): string => const eventLabel = (event: string, scope: string): string =>
(scope === 'user' ? NOTIFY_PERSONAL_EVENT_LABEL[event] : undefined) ?? T.eventLabel[event] ?? event (scope === 'user' ? NOTIFY_PERSONAL_EVENT_LABEL[event] : undefined) ?? T.eventLabel[event] ?? event
/** 一个渠道发到哪:Webhook 只有主机名;邮件写收件人(个人渠道是账号邮箱) */
function targetOf(row: NotifyChannelRow): string {
if (row.format !== 'email') return row.url_host
if (row.scope === 'user') return M.personalTo(row.mail_to?.[0] ?? null)
return M.to(row.mail_to ?? [])
}
export function NotifyTab() { export function NotifyTab() {
const builder = useAtLeast('builder') const builder = useAtLeast('builder')
const [rows, setRows] = useState<NotifyChannelRow[] | null>(null) const [rows, setRows] = useState<NotifyChannelRow[] | null>(null)
const [mail, setMail] = useState<NotifyMailState>({ enabled: false, account_email: null })
const [error, setError] = useState<unknown>(null) const [error, setError] = useState<unknown>(null)
const [editing, setEditing] = useState<NotifyChannelRow | 'new' | null>(null) const [editing, setEditing] = useState<NotifyChannelRow | 'new' | null>(null)
const [testing, setTesting] = useState<string | null>(null) const [testing, setTesting] = useState<string | null>(null)
const load = useCallback(() => { const load = useCallback(() => {
api.notify.channels() api.notify.channels()
.then(({ items }) => { setRows(builder ? items : items.filter((c) => c.scope === 'user')); setError(null) }) .then(({ items, mail: state }) => {
setRows(builder ? items : items.filter((c) => c.scope === 'user'))
setMail(state ?? { enabled: false, account_email: null })
setError(null)
})
.catch(setError) .catch(setError)
}, [builder]) }, [builder])
useEffect(load, [load]) useEffect(load, [load])
@@ -40,7 +58,8 @@ export function NotifyTab() {
setTesting(row.id) setTesting(row.id)
try { try {
const res = await api.notify.testChannel(row.id) const res = await api.notify.testChannel(row.id)
if (res.ok) toast.ok(T.testOk) // 邮件渠道成功时服务端说的是「已交给邮件服务器……」:照原话写,不写「已发送」
if (res.ok) toast.ok(row.format === 'email' ? res.message : T.testOk)
else toast.warn(res.message) else toast.warn(res.message)
} catch (e) { } catch (e) {
toast.error(e) toast.error(e)
@@ -79,7 +98,8 @@ export function NotifyTab() {
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{rows.map((row) => ( {rows.map((row) => (
<div key={row.id} className="flex items-center gap-2 rounded-lg border bg-panel px-3 py-2" data-notify-channel={row.name}> <div key={row.id} className="flex items-center gap-2 rounded-lg border bg-panel px-3 py-2" data-notify-channel={row.name}
data-notify-format={row.format}>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-xs"> <div className="flex items-center gap-1.5 text-xs">
<span className="font-medium">{row.name}</span> <span className="font-medium">{row.name}</span>
@@ -89,7 +109,10 @@ export function NotifyTab() {
{!row.enabled && <span className="chip text-faint">已停用</span>} {!row.enabled && <span className="chip text-faint">已停用</span>}
</div> </div>
<div className="truncate text-2xs text-faint"> <div className="truncate text-2xs text-faint">
<span className="mono">{row.url_host}</span> <span className={row.format === 'email' ? undefined : 'mono'} data-notify-target=""
style={row.format === 'email' && row.scope === 'user' && !row.mail_to?.length ? { color: 'var(--warn)' } : undefined}>
{targetOf(row)}
</span>
{row.events.length > 0 && ` · ${row.events.map((e) => eventLabel(e, row.scope)).join('、')}`} {row.events.length > 0 && ` · ${row.events.map((e) => eventLabel(e, row.scope)).join('、')}`}
</div> </div>
</div> </div>
@@ -108,7 +131,9 @@ export function NotifyTab() {
)} )}
</section> </section>
{builder && <Deliveries />} {builder && <Deliveries />}
{editing && <ChannelEditor row={editing === 'new' ? null : editing} onClose={() => setEditing(null)} {builder && <MailSection onSaved={load} />}
{builder && <DigestSection />}
{editing && <ChannelEditor row={editing === 'new' ? null : editing} mail={mail} onClose={() => setEditing(null)}
onSaved={(name) => { setEditing(null); toast.ok(T.saved(name)); load() }} />} onSaved={(name) => { setEditing(null); toast.ok(T.saved(name)); load() }} />}
</div> </div>
) )
@@ -123,11 +148,13 @@ function Deliveries() {
{!rows ? <Skeleton rows={2} height={28} /> : !rows.length ? <p className="text-2xs text-faint">{T.deliveriesEmpty}</p> : ( {!rows ? <Skeleton rows={2} height={28} /> : !rows.length ? <p className="text-2xs text-faint">{T.deliveriesEmpty}</p> : (
<div className="divide-y rounded-lg border text-2xs"> <div className="divide-y rounded-lg border text-2xs">
{rows.map((d) => ( {rows.map((d) => (
<div key={d.id} className="flex items-center gap-2 px-3 py-1.5"> <div key={d.id} className="flex items-center gap-2 px-3 py-1.5" data-delivery-format={d.format ?? ''}>
<span className="w-28 shrink-0 text-dim">{T.deliveryEventLabel[d.event] ?? T.eventLabel[d.event] ?? d.event}</span> <span className="w-28 shrink-0 text-dim">{T.deliveryEventLabel[d.event] ?? T.eventLabel[d.event] ?? d.event}</span>
<span className="min-w-0 flex-1 truncate text-faint">{d.error ?? (d.http_status ? `HTTP ${d.http_status}` : '')}</span> <span className="min-w-0 flex-1 truncate text-faint">{d.error ?? (d.http_status ? `HTTP ${d.http_status}` : '')}</span>
<span className="shrink-0" style={{ color: d.status === 'ok' ? 'var(--ok)' : d.status === 'failed' ? 'var(--st-failed)' : undefined }}> {/* 邮件只知道交给了邮件服务器,进没进收件箱不知道(退信不追踪):不写「已送达」 */}
{d.status === 'ok' ? '已送达' : d.status === 'failed' ? '失败' : d.status} <span className="shrink-0" title={d.format === 'email' && d.status === 'ok' ? M.handoffHint : undefined}
style={{ color: d.status === 'ok' ? 'var(--ok)' : d.status === 'failed' ? 'var(--st-failed)' : undefined }}>
{d.status === 'ok' ? (d.format === 'email' ? M.handoff : '已送达') : d.status === 'failed' ? '失败' : d.status}
</span> </span>
<time className="tnum shrink-0 text-faint">{formatDateTime(d.created_at ?? d.at)}</time> <time className="tnum shrink-0 text-faint">{formatDateTime(d.created_at ?? d.at)}</time>
</div> </div>
@@ -138,14 +165,16 @@ function Deliveries() {
) )
} }
function ChannelEditor({ row, onClose, onSaved }: { function ChannelEditor({ row, mail, onClose, onSaved }: {
row: NotifyChannelRow | null; onClose: () => void; onSaved: (name: string) => void row: NotifyChannelRow | null; mail: NotifyMailState; onClose: () => void; onSaved: (name: string) => void
}) { }) {
const admin = useAtLeast('admin') const admin = useAtLeast('admin')
const builder = useAtLeast('builder') const builder = useAtLeast('builder')
const [form, setForm] = useState({ const [form, setForm] = useState({
name: row?.name ?? '', scope: row?.scope ?? (admin ? 'global' : 'user'), format: row?.format ?? 'generic', name: row?.name ?? '', scope: row?.scope ?? (admin ? 'global' : 'user'), format: row?.format ?? 'generic',
url: '', secret: '', events: row?.events ?? ['task.opened', 'action.unknown'], url: '', secret: '', events: row?.events ?? ['task.opened', 'action.unknown'],
// 全局、应用的邮件渠道:收件地址整份重填(服务端照原样给,编辑时带出来)
recipients: row?.format === 'email' && row.scope !== 'user' ? (row.mail_to ?? []).join('\n') : '',
}) })
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
// 列哪些事件由服务端说(notify/service.EVENTS 的 scopes):这种范围的渠道收得到的才列,登记了还没推送的不列(勾了收不到)。 // 列哪些事件由服务端说(notify/service.EVENTS 的 scopes):这种范围的渠道收得到的才列,登记了还没推送的不列(勾了收不到)。
@@ -155,18 +184,28 @@ function ChannelEditor({ row, onClose, onSaved }: {
useEffect(() => { api.notify.events().then(setRegistry).catch(() => setRegistryFailed(true)) }, []) useEffect(() => { api.notify.events().then(setRegistry).catch(() => setRegistryFailed(true)) }, [])
const offered = EVENTS.filter((ev) => form.events.includes(ev) || row?.events.includes(ev) const offered = EVENTS.filter((ev) => form.events.includes(ev) || row?.events.includes(ev)
|| registry?.some((r) => r.event === ev && r.scopes.includes(form.scope))) || registry?.some((r) => r.event === ev && r.scopes.includes(form.scope)))
const email = form.format === 'email'
// 邮件服务没启用:不列「邮件」(建了也发不出去);原来就是邮件渠道的照列,能改名、改订阅、换回 Webhook
const formats = [...WEBHOOK_FORMATS, ...(mail.enabled || row?.format === 'email' ? ['email' as const] : [])]
const personalMail = email && form.scope === 'user'
const recipientsChanged = form.recipients.trim() !== (row?.format === 'email' ? (row.mail_to ?? []).join('\n') : '')
const missingTarget = email ? !personalMail && !form.recipients.trim()
: !row || row.format === 'email' ? !form.url.trim() : false
const save = async () => { const save = async () => {
setBusy(true) setBusy(true)
try { try {
const target = email
? (personalMail ? {} : recipientsChanged || row?.format !== 'email' ? { url: form.recipients.trim() } : {})
: form.url.trim() ? { url: form.url.trim() } : {}
if (row) { if (row) {
await api.notify.updateChannel(row.id, { await api.notify.updateChannel(row.id, {
name: form.name.trim(), scope: form.scope, format: form.format, events: form.events, name: form.name.trim(), scope: form.scope, format: form.format, events: form.events,
// 地址、密钥只回主机名:不填就是保持原值 // 地址、密钥只回主机名:不填就是保持原值
...(form.url.trim() ? { url: form.url.trim() } : {}), ...(form.secret ? { secret: form.secret } : {}), ...target, ...(!email && form.secret ? { secret: form.secret } : {}),
}) })
} else { } else {
await api.notify.createChannel({ name: form.name.trim(), scope: form.scope, format: form.format, url: form.url.trim(), await api.notify.createChannel({ name: form.name.trim(), scope: form.scope, format: form.format, ...target,
secret: form.secret || null, events: form.events }) secret: !email && form.secret ? form.secret : null, events: form.events })
} }
onSaved(form.name.trim()) onSaved(form.name.trim())
} catch (e) { } catch (e) {
@@ -179,7 +218,7 @@ function ChannelEditor({ row, onClose, onSaved }: {
<Modal open onClose={onClose} title={row ? `${T.title}:${row.name}` : T.create} width={560} dirty <Modal open onClose={onClose} title={row ? `${T.title}:${row.name}` : T.create} width={560} dirty
footer={<> footer={<>
<button className="btn" onClick={onClose}>取消</button> <button className="btn" onClick={onClose}>取消</button>
<button className="btn btn-primary" disabled={busy || !form.name.trim() || (!row && !form.url.trim())} <button className="btn btn-primary" disabled={busy || !form.name.trim() || missingTarget}
onClick={() => void save()} data-notify-save=""> onClick={() => void save()} data-notify-save="">
{busy ? <Spinner size={11} /> : null} 保存 {busy ? <Spinner size={11} /> : null} 保存
</button> </button>
@@ -191,9 +230,9 @@ function ChannelEditor({ row, onClose, onSaved }: {
</Field> </Field>
<Field label={T.format}> <Field label={T.format}>
{(p) => ( {(p) => (
<select {...p} className="field" value={form.format} <select {...p} className="field" value={form.format} data-notify-format-select=""
onChange={(e) => setForm({ ...form, format: e.target.value as typeof form.format })}> onChange={(e) => setForm({ ...form, format: e.target.value as typeof form.format })}>
{FORMATS.map((f) => <option key={f} value={f}>{T.formats[f]}</option>)} {formats.map((f) => <option key={f} value={f}>{T.formats[f]}</option>)}
</select> </select>
)} )}
</Field> </Field>
@@ -207,14 +246,32 @@ function ChannelEditor({ row, onClose, onSaved }: {
)} )}
</Field> </Field>
</div> </div>
<Field label={T.url} hint={row ? `${T.urlHint};当前:${row.url_host}` : T.urlHint} required={!row}> {!mail.enabled && !email && <p className="text-2xs text-faint" data-notify-mail-off="">{M.off}</p>}
{(p) => <input {...p} className="field mono text-xs" value={form.url} placeholder="https://" {personalMail ? (
onChange={(e) => setForm({ ...form, url: e.target.value })} />} <div className="rounded-md border bg-elev px-2.5 py-2 text-xs" data-notify-mail-to="account">
</Field> <div className="flex items-center gap-1.5" style={mail.account_email ? undefined : { color: 'var(--warn)' }}>
<Field label={T.secret}> <Mail size={12} aria-hidden /> {M.personalTo(mail.account_email)}
{(p) => <input {...p} className="field mono text-xs" type="password" autoComplete="off" value={form.secret} </div>
onChange={(e) => setForm({ ...form, secret: e.target.value })} />} <p className="mt-0.5 text-2xs text-faint">{M.personalToHint}</p>
</Field> </div>
) : email ? (
<Field label={M.recipients} hint={M.recipientsHint} required>
{(p) => <textarea {...p} className="field mono min-h-[72px] text-xs" value={form.recipients} data-notify-recipients=""
placeholder="ops@example.com" onChange={(e) => setForm({ ...form, recipients: e.target.value })} />}
</Field>
) : (
<>
<Field label={T.url} hint={row && row.format !== 'email' ? `${T.urlHint};当前:${row.url_host}` : T.urlHint}
required={!row || row.format === 'email'}>
{(p) => <input {...p} className="field mono text-xs" value={form.url} placeholder="https://"
onChange={(e) => setForm({ ...form, url: e.target.value })} />}
</Field>
<Field label={T.secret}>
{(p) => <input {...p} className="field mono text-xs" type="password" autoComplete="off" value={form.secret}
onChange={(e) => setForm({ ...form, secret: e.target.value })} />}
</Field>
</>
)}
<fieldset> <fieldset>
<legend className="label">{T.events}</legend> <legend className="label">{T.events}</legend>
<div className="grid grid-cols-3 gap-1.5" data-notify-events={registry ? form.scope : ''}> <div className="grid grid-cols-3 gap-1.5" data-notify-events={registry ? form.scope : ''}>
@@ -233,3 +290,200 @@ function ChannelEditor({ row, onClose, onSaved }: {
</Modal> </Modal>
) )
} }
// -------------------------------------------------------------------------
// 邮件服务(设置的 mail 一组)、每天的待办汇总(notify 一组的 digest_at)
// -------------------------------------------------------------------------
interface MailSettings {
enabled: boolean; host: string | null; port: number; security: string; username: string | null
password: null; password_set: boolean; from_addr: string | null; from_name: string; default_for_assignees: boolean
}
interface MailDraft {
enabled: boolean; host: string; port: string; security: string; username: string; password: string
clearPassword: boolean; from_addr: string; from_name: string; default_for_assignees: boolean
}
const draftOf = (m: MailSettings): MailDraft => ({
enabled: m.enabled, host: m.host ?? '', port: String(m.port ?? 587), security: m.security ?? 'starttls',
username: m.username ?? '', password: '', clearPassword: false, from_addr: m.from_addr ?? '',
from_name: m.from_name ?? 'AgentLab', default_for_assignees: m.default_for_assignees,
})
/**
* 「邮件服务」一节:管理员改,搭建者只看。口令不回,只说设没设;留空表示不修改,勾「清除」才去掉。保存一整组(服务端
* 补齐没给的);测试邮件按已保存的设置发到管理员自己的账号邮箱,有没保存的改动时先保存
*/
function MailSection({ onSaved }: { onSaved: () => void }) {
const admin = useAtLeast('admin')
const [saved, setSaved] = useState<MailSettings | null>(null)
const [loadError, setLoadError] = useState<unknown>(null)
const [draft, setDraft] = useState<MailDraft | null>(null)
const [busy, setBusy] = useState(false)
const [testing, setTesting] = useState(false)
const load = () => api.settings.get().then((s) => { setSaved(s.mail ?? null); setLoadError(null) }, setLoadError)
useEffect(() => { void load() }, [])
const base = saved ? draftOf(saved) : null
const cur = draft ?? base
const dirty = !!cur && !!base && JSON.stringify(cur) !== JSON.stringify(base)
useLeaveGuard(dirty, () => confirmDialog({
title: M.section, body: M.leaveBody, confirmLabel: '放弃修改', cancelLabel: '返回保存', danger: true,
}))
const portBad = !!cur && !(/^\d+$/.test(cur.port.trim()) && Number(cur.port) >= 1 && Number(cur.port) <= 65535)
const edit = (patch: Partial<MailDraft>) => cur && setDraft({ ...cur, ...patch })
const save = async () => {
if (!cur || portBad || busy) return
setBusy(true)
try {
const out = await api.settings.put({ mail: {
enabled: cur.enabled, host: cur.host.trim() || null, port: Number(cur.port), security: cur.security,
username: cur.username.trim() || null, from_addr: cur.from_addr.trim() || null, from_name: cur.from_name.trim() || null,
default_for_assignees: cur.default_for_assignees,
...(cur.clearPassword ? { password: '' } : cur.password ? { password: cur.password } : {}),
} })
setSaved(out.mail)
setDraft(null)
toast.ok(M.saved)
onSaved()
} catch (e) {
toast.error(e)
} finally {
setBusy(false)
}
}
const test = async () => {
setTesting(true)
try {
const res = await api.settings.mailTest()
if (res.ok) toast.ok(res.message)
else toast.warn(res.message)
} catch (e) {
toast.error(e)
} finally {
setTesting(false)
}
}
return (
<section data-mail-section={admin ? 'edit' : 'readonly'}>
<SectionBar title={M.section} hint={admin ? M.hint : `${M.hint}。${M.readOnly}`} />
{!cur ? (loadError ? <ErrorState error={loadError} onRetry={() => void load()} /> : <Skeleton rows={3} height={14} />) : (
<fieldset disabled={!admin} className="space-y-3">
<label className="flex items-start gap-2 text-xs">
<input type="checkbox" className="mt-0.5" checked={cur.enabled} data-mail-field="enabled"
onChange={(e) => edit({ enabled: e.target.checked })} />
<span>{M.enabled}<span className="mt-0.5 block text-2xs text-faint">{M.enabledHint}</span></span>
</label>
<div className="grid grid-cols-3 gap-3">
<Field label={M.host} hint={M.hostHint} className="col-span-2">
{(p) => <input {...p} className="field mono text-xs" value={cur.host} placeholder="smtp.example.com"
data-mail-field="host" onChange={(e) => edit({ host: e.target.value })} />}
</Field>
<Field label={M.port} error={portBad ? M.invalidPort : undefined}>
{(p) => <input {...p} className="field tnum text-xs" inputMode="numeric" value={cur.port} data-mail-field="port"
onChange={(e) => edit({ port: e.target.value })} />}
</Field>
</div>
<Field label={M.security}>
{(p) => (
<select {...p} className="field max-w-72 text-xs" value={cur.security} data-mail-field="security"
onChange={(e) => edit({ security: e.target.value })}>
{Object.entries(M.securityLabel).map(([k, label]) => <option key={k} value={k}>{label}</option>)}
</select>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label={M.username} hint={M.usernameHint}>
{(p) => <input {...p} className="field mono text-xs" value={cur.username} autoComplete="off" data-mail-field="username"
onChange={(e) => edit({ username: e.target.value })} />}
</Field>
<Field label={M.password} hint={saved?.password_set ? M.passwordSet : M.passwordUnset}>
{(p) => <input {...p} className="field mono text-xs" type="password" autoComplete="new-password" value={cur.password}
data-mail-field="password" disabled={cur.clearPassword}
onChange={(e) => edit({ password: e.target.value })} />}
</Field>
</div>
{saved?.password_set && (
<label className="flex items-center gap-1.5 text-2xs text-dim">
<input type="checkbox" checked={cur.clearPassword} data-mail-field="clear-password"
onChange={(e) => edit({ clearPassword: e.target.checked, password: '' })} /> {M.passwordClear}
</label>
)}
<div className="grid grid-cols-2 gap-3">
<Field label={M.fromAddr}>
{(p) => <input {...p} className="field mono text-xs" value={cur.from_addr} placeholder="agentlab@example.com"
data-mail-field="from_addr" onChange={(e) => edit({ from_addr: e.target.value })} />}
</Field>
<Field label={M.fromName}>
{(p) => <input {...p} className="field text-xs" value={cur.from_name} data-mail-field="from_name"
onChange={(e) => edit({ from_name: e.target.value })} />}
</Field>
</div>
<label className="flex items-start gap-2 text-xs">
<input type="checkbox" className="mt-0.5" checked={cur.default_for_assignees} data-mail-field="default_for_assignees"
onChange={(e) => edit({ default_for_assignees: e.target.checked })} />
<span>{M.defaultAssignees}<span className="mt-0.5 block text-2xs leading-relaxed text-faint">{M.defaultAssigneesHint}</span></span>
</label>
{admin && (
<div className="flex flex-wrap items-center gap-2">
<button type="button" className="btn btn-sm btn-primary" disabled={!dirty || portBad || busy} onClick={() => void save()}
data-mail-save="">
{busy ? <Spinner size={11} /> : <Check size={12} aria-hidden />} {M.save}
</button>
<button type="button" className="btn btn-sm" disabled={dirty || testing} onClick={() => void test()}
title={dirty ? M.testDirty : M.testHint} data-mail-test="">
{testing ? <Spinner size={11} /> : <Send size={11} aria-hidden />} {M.test}
</button>
<span className="text-2xs text-faint">{dirty ? M.testDirty : M.testHint}</span>
</div>
)}
<p className="text-2xs leading-relaxed text-faint" data-mail-handoff="">{M.handoffHint}</p>
</fieldset>
)}
</section>
)
}
/** 「每天的待办汇总」:几点发(服务端时区)。只改 notify 一组的这一项,服务端补上对外访问地址 */
function DigestSection() {
const admin = useAtLeast('admin')
const [saved, setSaved] = useState<string | null>(null)
const [draft, setDraft] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => { api.settings.get().then((s) => setSaved(s.notify?.digest_at ?? '09:00'), () => setSaved(null)) }, [])
if (saved == null) return null
const cur = draft ?? saved
const valid = /^([01]\d|2[0-3]):[0-5]\d$/.test(cur)
const save = async () => {
if (!valid || busy) return
setBusy(true)
try {
const out = await api.settings.put({ notify: { digest_at: cur } })
setSaved(out.notify?.digest_at ?? cur)
setDraft(null)
toast.ok(M.digestSaved(out.notify?.digest_at ?? cur))
} catch (e) {
toast.error(e)
} finally {
setBusy(false)
}
}
return (
<section data-digest-section={admin ? 'edit' : 'readonly'}>
<SectionBar title={M.digest} hint={admin ? M.digestHint : `${M.digestHint}。${M.digestReadOnly}`} />
<div className="flex flex-wrap items-end gap-2">
<Field label={M.digestAt}>
{(p) => <input {...p} type="time" className="field tnum w-32 text-xs" value={cur} disabled={!admin} data-digest-at=""
onChange={(e) => setDraft(e.target.value)} />}
</Field>
{admin && (
<button type="button" className="btn btn-sm" disabled={cur === saved || !valid || busy} onClick={() => void save()}
data-digest-save="">
{busy ? <Spinner size={11} /> : <Check size={12} aria-hidden />} {M.digestSave}
</button>
)}
</div>
</section>
)
}
+72 -2
View File
@@ -456,6 +456,11 @@ export interface ViewNode {
* 使用面不下发隐藏的组件 * 使用面不下发隐藏的组件
*/ */
hidden?: boolean hidden?: boolean
/**
* 分享链接里(resolve_page 的 share 旁路)被换成说明块的组件原来是什么(entry_button、task_list、report……):type 已经是
* notice,props.text 写着为什么不显示。别处没有这一项
*/
share_placeholder?: string
/** /**
* 服务端解析好的数据(backend/app/apps/resolve.py 的 _data),形状随组件而定;绑定了视图的都带 state 和可选的 message: * 服务端解析好的数据(backend/app/apps/resolve.py 的 _data),形状随组件而定;绑定了视图的都带 state 和可选的 message:
* - stamp:{view, state, stamp};metric:{state, text, name, unit, evidence} * - stamp:{view, state, stamp};metric:{state, text, name, unit, evidence}
@@ -720,12 +725,77 @@ export interface NotifyChannelRow {
name: string name: string
scope: 'global' | 'app' | 'user' scope: 'global' | 'app' | 'user'
owner_id: string | null owner_id: string | null
format: 'generic' | 'feishu' | 'wecom' | 'dingtalk' /** email:邮件(第五种格式,服务端 notify/mail.py,04 细案 B2) */
/** 只回主机名 */ format: 'generic' | 'feishu' | 'wecom' | 'dingtalk' | 'email'
/** 只回主机名;邮件渠道是空串 */
url_host: string url_host: string
signed: boolean signed: boolean
events: string[] events: string[]
enabled: boolean enabled: boolean
/**
* 邮件渠道发给谁:个人渠道是拥有者的账号邮箱(没有邮箱是空的,发不出去),全局、应用的是填的那几个收件地址。
* Webhook 渠道没有这一项
*/
mail_to?: string[]
}
/**
* GET /api/notify/channels 另给的 mail:邮件服务(设置的 mail 一组)启没启用、本人的账号邮箱(关登录时为 null)。
* 设置页据此列不列「邮件」这一格式、个人邮件渠道写「发到账号邮箱 …」。老服务端不给时按没启用
*/
export interface NotifyMailState {
enabled: boolean
account_email: string | null
}
/**
* 分享链接(服务端 api/shares.py,04 细案 C2):管理应用的人在应用编辑里建、撤销。key 只在建的时候交回一次
* (ViewShareCreated),之后只给开头一截(prefix)认是哪一条
*/
export interface ViewShareRow {
id: string
prefix: string
app_id: string
page_id: string
page_title: string
/** latest:始终是最近一次封存;fixed:固定在建分享那一刻每个视图取的运行 */
mode: 'latest' | 'fixed'
app_version: number | null
embed_allowed: boolean
created_by: string | null
created_by_name: string | null
created_at: string | null
expires_at: string | null
revoked_at: string | null
revoked_by: string | null
open_count: number
last_opened_at: string | null
state: 'active' | 'revoked' | 'expired'
}
export interface ViewShareCreated extends ViewShareRow {
key: string
/** /s#<key> */
path: string
/** 拼上对外访问地址的完整链接;没设对外访问地址时为 null,界面按当前页面的地址拼 */
url: string | null
}
export interface ViewShareList {
/** 管理员开没开「允许建分享链接」:关着时不给新建,已有的照列、能撤销 */
enabled: boolean
/** 部署配没配允许嵌入分享页的站点(AGENTLAB_EMBED_ORIGINS) */
embed_origins: boolean
max_days: number
items: ViewShareRow[]
}
/** 分享页(GET /api/shares/page,agentlab.share/1):页脚要的、按分享解析的视图模型、能下载报告的那几次运行 */
export interface SharePayload {
format: 'agentlab.share/1'
share: { mode: 'latest' | 'fixed'; created_by: string | null; created_at: string; expires_at: string; embed_allowed: boolean }
view: ViewModel
downloads: { view: string; run_id: string; reports: string[]; as_of_text?: string | null }[]
} }
/** /**
+5 -1
View File
@@ -6,7 +6,7 @@
// 守的是 2026-09 文案整改定下的写法(术语表和语体规范见 frontend/src/lib/terms.ts 开头): // 守的是 2026-09 文案整改定下的写法(术语表和语体规范见 frontend/src/lib/terms.ts 开头):
// - 「跑」作动词(工作流写「运行」,节点、工具写「执行」)、后端(写「服务端」)、多半、照样、眼下、压根、 // - 「跑」作动词(工作流写「运行」,节点、工具写「执行」)、后端(写「服务端」)、多半、照样、眼下、压根、
// 取不到、没取回来、读不懂、连不上、写坏了(写「无法 / 未能 / 失败」)、 // 取不到、没取回来、读不懂、连不上、写坏了(写「无法 / 未能 / 失败」)、
// 令牌 / tok / tokens(写「token」)、钉住(写「固定版本」「引用版本」)、智能体(写「Agent」)、 // 令牌 / tok / tokens(写「token」)、快照链接(写「分享链接」)、钉住(写「固定版本」「引用版本」)、智能体(写「Agent」)、
// 供应商(写「模型接入」)、界面上的 Copilot(写「助手」); // 供应商(写「模型接入」)、界面上的 Copilot(写「助手」);
// - 句尾语气词(吧、呢、哦……); // - 句尾语气词(吧、呢、哦……);
// - 句子里裸写的配置键名和枚举值(report_from、require_citation、cells: true……),要写界面上的中文标签; // - 句子里裸写的配置键名和枚举值(report_from、require_citation、cells: true……),要写界面上的中文标签;
@@ -67,6 +67,8 @@ const RULES = [
test: (t) => t.match(/.{0,6}(?:多半|照样|眼下|压根|取不到|没取回来|读不懂|连不上|写坏了).{0,6}/)?.[0], test: (t) => t.match(/.{0,6}(?:多半|照样|眼下|压根|取不到|没取回来|读不懂|连不上|写坏了).{0,6}/)?.[0],
}, },
{ id: '令牌', what: '「令牌」:写「token」', test: (t) => t.match(/.{0,6}令牌.{0,6}/)?.[0] }, { id: '令牌', what: '「令牌」:写「token」', test: (t) => t.match(/.{0,6}令牌.{0,6}/)?.[0] },
// 总方案 X10:不记名的只读链接叫「分享链接」,「快照」专指查询快照
{ id: '快照链接', what: '「快照链接」:写「分享链接」(「快照」专指查询快照)', test: (t) => t.match(/.{0,6}快照链接.{0,6}/)?.[0] },
{ {
id: 'tok', what: '「tok」「tokens」:写「token」(如「2.8k token」)', id: 'tok', what: '「tok」「tokens」:写「token」(如「2.8k token」)',
test: (t, prose) => (prose test: (t, prose) => (prose
@@ -462,6 +464,8 @@ console.log('\n=== 扫描器自检(拿构造的源码喂一遍)===')
&& !strings.some((s) => s.text === '跑') && !seen('./跑.ts') && !strings.some((s) => s.text === '后端')) && !strings.some((s) => s.text === '跑') && !seen('./跑.ts') && !strings.some((s) => s.text === '后端'))
check('合规的文案不误报', !lint('保存并继续运行', true).length && !lint('56.0k token', false).length check('合规的文案不误报', !lint('保存并继续运行', true).length && !lint('56.0k token', false).length
&& !lint('已达上限(3 句),这句未裁判', true).length) && !lint('已达上限(3 句),这句未裁判', true).length)
check('「快照链接」报出来,「分享链接」「查询快照」不误报', lint('复制快照链接', true).some((h) => h.rule === '快照链接')
&& !lint('复制分享链接', true).length && !lint('打开查询快照', true).length)
const py = [ const py = [
'from fastapi import HTTPException', 'from fastapi import HTTPException',