feat(client): slice 6 — minimal login per spec + high-risk approval dialog
Aligns the client with docs/product-package/{08,11}.md.
6a — Login surface trimmed to wireframe §1:
- Brand wordmark + tagline "从一个想法,到可上线的软件产品"
- Footer line "登录后,客户端会使用 Heicode 提供的模型。"
- ProviderLoginCard now shows ONLY: sign-in target host (read-only),
email + password, sign-in button. The local-network warning tag,
raw baseUrl pill, promo paragraph, "or via browser" alt link, and
the "RECOMMENDED" badge — all dropped per the wireframe's "登录目
标只有 Heicode" intent.
- Removed dead handleOAuth / shellOpen / isLocalBaseUrl helpers.
6b — High-risk approval dialog (08-client-guide.md §"高危审批体验"
+ 11-product-prototype-wireframes.md §9):
- New zustand store stores/approvalStore.ts with a FIFO queue of
ApprovalRequest items + decide(id, 'approve' | 'reject' |
'postpone') action. Idempotent enqueue (dedupe by id).
- New components/approval/ApprovalDialog.tsx renders queue[0] as a
modal with the 6 spec fields (task / operation / target / role /
impact / credential), a Heicode-suggestion sidebar, a risk-level
pill, and 3 actions: 拒绝 / 稍后提醒 / 批准 N 分钟. Queue depth
badge appears at the bottom when more requests are pending.
- AppShell renders <ApprovalDialog /> alongside ToastContainer so it
overlays any surface (sessions, settings, etc.).
- Backend wiring pending — for now main.tsx calls
installApprovalMock() which exposes window.__heicodeMockApproval()
for DevTools-driven demos. Real backend hook lands when
mcp-server / agent-manager publish the approval-stream contract.
i18n: added login.tagline / login.signInTarget /
login.footer.heicodeProvidesModels and a full approval.* set
(zh + en, ~17 keys per locale).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
// desktop/src/components/approval/ApprovalDialog.tsx
|
||||
//
|
||||
// High-risk approval dialog rendered by AppShell whenever
|
||||
// useApprovalStore().queue is non-empty. The first item in the queue is
|
||||
// shown; user picks 拒绝 / 稍后提醒 / 批准 to drain it.
|
||||
//
|
||||
// Per docs/product-package/11-product-prototype-wireframes.md §9 the
|
||||
// dialog must surface: task name, operation, target resource, risk level,
|
||||
// short-lived credential note, Heicode's suggestion, and three actions.
|
||||
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
useApprovalStore,
|
||||
type ApprovalRequest,
|
||||
type ApprovalRiskLevel,
|
||||
} from '../../stores/approvalStore'
|
||||
|
||||
const RISK_TONE: Record<ApprovalRiskLevel, string> = {
|
||||
low: 'border-[var(--color-success)]/30 bg-[var(--color-success)]/10 text-[var(--color-success)]',
|
||||
medium:
|
||||
'border-[var(--color-warning)]/30 bg-[var(--color-warning)]/10 text-[var(--color-warning)]',
|
||||
high: 'border-[var(--color-error)]/30 bg-[var(--color-error-container)] text-[var(--color-error)]',
|
||||
critical:
|
||||
'border-[var(--color-error)]/50 bg-[var(--color-error)] text-white',
|
||||
}
|
||||
|
||||
export function ApprovalDialog() {
|
||||
const t = useTranslation()
|
||||
const queue = useApprovalStore((s) => s.queue)
|
||||
const decide = useApprovalStore((s) => s.decide)
|
||||
|
||||
const current = queue[0]
|
||||
if (!current) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-[var(--color-overlay-scrim)] px-4 py-8"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="heicode-approval-title"
|
||||
>
|
||||
<div className="w-full max-w-xl overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] shadow-[var(--shadow-dropdown)]">
|
||||
<header className="flex items-center justify-between border-b border-[var(--color-border-separator)] px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="material-symbols-outlined text-[20px] text-[var(--color-warning)]"
|
||||
aria-hidden
|
||||
>
|
||||
shield_lock
|
||||
</span>
|
||||
<h2
|
||||
id="heicode-approval-title"
|
||||
className="text-base font-semibold text-[var(--color-text-primary)]"
|
||||
style={{ fontFamily: 'var(--font-headline)' }}
|
||||
>
|
||||
{t('approval.title')}
|
||||
</h2>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ${RISK_TONE[current.risk_level]}`}
|
||||
>
|
||||
{t(`approval.risk.${current.risk_level}`)}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<dl className="grid gap-3 px-6 py-5 text-sm">
|
||||
<Row label={t('approval.field.task')} value={current.task_name} />
|
||||
<Row label={t('approval.field.operation')} value={current.operation} mono />
|
||||
<Row label={t('approval.field.targetResource')} value={current.target_resource} mono />
|
||||
{current.requesting_role ? (
|
||||
<Row label={t('approval.field.requestingRole')} value={current.requesting_role} mono />
|
||||
) : null}
|
||||
{current.impact_summary ? (
|
||||
<Row label={t('approval.field.impact')} value={current.impact_summary} />
|
||||
) : null}
|
||||
<Row
|
||||
label={t('approval.field.credential')}
|
||||
value={
|
||||
current.derives_short_lived_credential !== false
|
||||
? t('approval.credential.derived', {
|
||||
minutes: String(current.ttl_minutes ?? 15),
|
||||
})
|
||||
: t('approval.credential.notRequired')
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{current.heicode_suggestion ? (
|
||||
<aside className="mx-6 mb-5 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">
|
||||
{t('approval.suggestion.label')}
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-[var(--color-text-secondary)]">
|
||||
{current.heicode_suggestion}
|
||||
</p>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-[var(--color-border-separator)] px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide(current.id, 'reject')}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--color-error)]/30 px-3 py-1.5 text-sm text-[var(--color-error)] transition-colors hover:bg-[var(--color-error)]/10"
|
||||
>
|
||||
{t('approval.action.reject')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide(current.id, 'postpone')}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-sm text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
{t('approval.action.postpone')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide(current.id, 'approve')}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-3 py-1.5 text-sm font-medium text-[var(--color-on-primary)] transition-colors hover:bg-[var(--color-primary-fixed-dim)]"
|
||||
>
|
||||
{t('approval.action.approve', {
|
||||
minutes: String(current.ttl_minutes ?? 15),
|
||||
})}
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
{queue.length > 1 ? (
|
||||
<div className="border-t border-[var(--color-border-separator)] bg-[var(--color-surface-container-low)] px-6 py-2 text-center text-[10px] text-[var(--color-text-tertiary)]">
|
||||
{t('approval.queue.more', { count: String(queue.length - 1) })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
mono?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[110px_1fr] gap-3">
|
||||
<dt className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)] pt-1">
|
||||
{label}
|
||||
</dt>
|
||||
<dd
|
||||
className={`break-words text-[var(--color-text-primary)] ${mono ? 'font-mono text-[12px]' : ''}`}
|
||||
>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ApprovalRequestInput = Omit<ApprovalRequest, 'id' | 'enqueued_at'> & {
|
||||
id?: string
|
||||
enqueued_at?: number
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/**
|
||||
* Dev / mock entry point — push a fake approval into the queue from
|
||||
* DevTools console:
|
||||
* window.__heicodeMockApproval({ task_name: '...', operation: '...', ... })
|
||||
*/
|
||||
__heicodeMockApproval?: (input?: ApprovalRequestInput) => void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the global mock entry point. Call this once at app startup.
|
||||
*/
|
||||
export function installApprovalMock() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.__heicodeMockApproval = (input) => {
|
||||
const req: Omit<ApprovalRequest, 'enqueued_at'> = {
|
||||
id: input?.id ?? `mock-${Date.now()}`,
|
||||
task_name: input?.task_name ?? '小团队任务管理 SaaS',
|
||||
operation: input?.operation ?? '部署到生产环境',
|
||||
target_resource: input?.target_resource ?? 'aks-prod',
|
||||
requesting_role: input?.requesting_role ?? 'ops',
|
||||
risk_level: input?.risk_level ?? 'high',
|
||||
impact_summary:
|
||||
input?.impact_summary ??
|
||||
'会更新对外服务,影响所有线上用户。回滚需要重新触发部署流程。',
|
||||
heicode_suggestion:
|
||||
input?.heicode_suggestion ?? '先完成测试环境验证,再批准生产部署。',
|
||||
derives_short_lived_credential:
|
||||
input?.derives_short_lived_credential ?? true,
|
||||
ttl_minutes: input?.ttl_minutes ?? 15,
|
||||
}
|
||||
useApprovalStore.getState().enqueue(req)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { ContentRouter } from './ContentRouter'
|
||||
import { ToastContainer } from '../shared/Toast'
|
||||
import { ApprovalDialog } from '../approval/ApprovalDialog'
|
||||
import { UpdateChecker } from '../shared/UpdateChecker'
|
||||
import { HeicodeLoginPage } from '../login/HeicodeLoginPage'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
@@ -124,6 +125,7 @@ export function AppShell() {
|
||||
</main>
|
||||
<ToastContainer />
|
||||
<UpdateChecker />
|
||||
<ApprovalDialog />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,12 +62,15 @@ export function HeicodeLoginPage() {
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative flex flex-1 flex-col items-center justify-center overflow-auto px-6 py-10">
|
||||
<div className="mb-12 text-center">
|
||||
<div className="mb-10 text-center">
|
||||
<h1 className="text-5xl font-bold tracking-[0.18em] uppercase text-[var(--color-brand)]">
|
||||
Heicode
|
||||
</h1>
|
||||
<p className="mt-4 text-sm text-[var(--color-text-secondary)]">
|
||||
{t('login.subtitle')}
|
||||
<p
|
||||
className="mt-5 text-sm text-[var(--color-text-secondary)]"
|
||||
style={{ fontFamily: 'var(--font-headline)' }}
|
||||
>
|
||||
{t('login.tagline')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -84,7 +87,7 @@ export function HeicodeLoginPage() {
|
||||
) : null}
|
||||
|
||||
{hasFetched && providers.length > 0 ? (
|
||||
<div className="grid w-full max-w-md grid-cols-1 gap-6">
|
||||
<div className="grid w-full max-w-md grid-cols-1 gap-5">
|
||||
{providers.map((provider) => (
|
||||
<ProviderLoginCard key={provider.id} provider={provider} />
|
||||
))}
|
||||
@@ -92,10 +95,14 @@ export function HeicodeLoginPage() {
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="mt-6 max-w-xl rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)] px-4 py-3 text-sm text-[var(--color-error)]">
|
||||
<div className="mt-6 max-w-md rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)] px-4 py-3 text-sm text-[var(--color-error)]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="mt-10 max-w-md text-center text-xs leading-relaxed text-[var(--color-text-tertiary)]">
|
||||
{t('login.footer.heicodeProvidesModels')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// desktop/src/components/login/ProviderLoginCard.tsx
|
||||
//
|
||||
// 登录卡片:
|
||||
// 主路径 — 邮箱+密码直登(路径 B,符合原设计文档)
|
||||
// 次路径 — 浏览器跳转 OAuth(保留作为快捷免密登录入口)
|
||||
// Minimal email + password sign-in card per
|
||||
// docs/product-package/11-product-prototype-wireframes.md §1.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { open as shellOpen } from '@tauri-apps/plugin-shell'
|
||||
import type { HeicodeLoginProviderInfo } from '../../api/heicodeAuth'
|
||||
import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -14,113 +12,60 @@ type Props = {
|
||||
provider: HeicodeLoginProviderInfo
|
||||
}
|
||||
|
||||
function isLocalBaseUrl(rawUrl: string): boolean {
|
||||
if (!rawUrl) return false
|
||||
let host: string
|
||||
try {
|
||||
const url = new URL(rawUrl)
|
||||
if (url.protocol === 'http:') return true
|
||||
host = url.hostname
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0') return true
|
||||
if (!host.includes('.')) return true
|
||||
if (host.startsWith('10.')) return true
|
||||
if (host.startsWith('192.168.')) return true
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function ProviderLoginCard({ provider }: Props) {
|
||||
const t = useTranslation()
|
||||
const {
|
||||
loginWithCredentials,
|
||||
startOAuth,
|
||||
startOAuthPolling,
|
||||
isLoggingIn,
|
||||
} = useHeicodeAuthStore()
|
||||
const { loginWithCredentials, isLoggingIn } = useHeicodeAuthStore()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState<'creds' | 'oauth' | null>(null)
|
||||
const [busy, setBusy] = useState<boolean>(false)
|
||||
|
||||
const handleCredentialsLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (busy !== null || isLoggingIn) return
|
||||
if (busy || isLoggingIn) return
|
||||
setLocalError(null)
|
||||
setBusy('creds')
|
||||
setBusy(true)
|
||||
try {
|
||||
await loginWithCredentials({
|
||||
providerId: provider.id,
|
||||
email: email.trim(),
|
||||
password,
|
||||
})
|
||||
// 登录成功后 store.status.loggedIn=true,AppShell 自动切到主界面
|
||||
// After success store.status.loggedIn=true → AppShell switches surface.
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOAuth = async () => {
|
||||
if (!provider.oauthEnabled) return
|
||||
setLocalError(null)
|
||||
setBusy('oauth')
|
||||
try {
|
||||
const { authorizeUrl } = await startOAuth(provider.id)
|
||||
try {
|
||||
await shellOpen(authorizeUrl)
|
||||
} catch {
|
||||
setLocalError(t('login.errors.openBrowser'))
|
||||
}
|
||||
startOAuthPolling()
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const local = isLocalBaseUrl(provider.baseUrl)
|
||||
const formDisabled = busy !== null || isLoggingIn
|
||||
const formDisabled = busy || isLoggingIn
|
||||
const submitEnabled = email.trim().length >= 3 && password.length >= 1 && !formDisabled
|
||||
|
||||
// Per docs/product-package/11-product-prototype-wireframes.md §1, the login
|
||||
// surface is intentionally minimal: brand wordmark + sign-in target host +
|
||||
// single sign-in action. We surface the host as a small, low-contrast label
|
||||
// (so the user knows where the credentials are going) but drop the raw
|
||||
// baseUrl pill, the promo text, the local-network warning tag, and the
|
||||
// "or via browser" alt link that earlier slices added.
|
||||
const signInHost = (() => {
|
||||
try {
|
||||
return new URL(provider.baseUrl).host
|
||||
} catch {
|
||||
return provider.baseUrl
|
||||
}
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] p-7 shadow-[var(--shadow-dropdown)] backdrop-blur-sm transition-colors duration-200 hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold tracking-wide text-[var(--color-text-primary)]">
|
||||
{provider.name}
|
||||
</h3>
|
||||
<span className="rounded-full border border-[var(--color-success)]/25 bg-[var(--color-success)]/10 px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[var(--color-success)]">
|
||||
{t('login.tags.recommended')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)]"
|
||||
data-testid={`heicode-login-card-baseurl-${provider.id}`}
|
||||
>
|
||||
<span className="opacity-70">{t('login.baseUrl.label')}</span>
|
||||
<code className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-text-secondary)] break-all">
|
||||
{provider.baseUrl}
|
||||
</code>
|
||||
{local ? (
|
||||
<span
|
||||
className="rounded-full border border-[var(--color-warning)]/25 bg-[var(--color-warning)]/10 px-2 py-0.5 text-[10px] uppercase tracking-wider text-[var(--color-warning)]"
|
||||
title={t('login.baseUrl.localHint')}
|
||||
>
|
||||
{t('login.baseUrl.localTag')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{provider.promoText ? (
|
||||
<p className="text-sm leading-relaxed text-[var(--color-text-secondary)]">
|
||||
{provider.promoText}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.2em] text-[var(--color-text-tertiary)]">
|
||||
{t('login.signInTarget')}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-1 font-mono text-xs text-[var(--color-text-secondary)] break-all">
|
||||
{signInHost}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ─── Primary: Email + Password ───────────────────────── */}
|
||||
<form onSubmit={handleCredentialsLogin} className="flex flex-col gap-3">
|
||||
@@ -160,24 +105,10 @@ export function ProviderLoginCard({ provider }: Props) {
|
||||
disabled={!submitEnabled}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-4 py-2.5 text-sm font-medium text-[var(--color-on-primary)] shadow-[var(--shadow-button-primary)] transition-all duration-200 hover:bg-[var(--color-primary-fixed-dim)] hover:shadow-[0_8px_24px_rgba(197,165,114,0.28)] disabled:cursor-not-allowed disabled:border-[var(--color-border)] disabled:bg-[var(--color-surface-container-high)] disabled:text-[var(--color-text-tertiary)] disabled:shadow-none"
|
||||
>
|
||||
{busy === 'creds' ? t('login.creds.submitting') : t('login.creds.submit')}
|
||||
{busy ? t('login.creds.submitting') : t('login.creds.submit')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* ─── Secondary: Browser OAuth (保留快捷入口) ─────────── */}
|
||||
{provider.oauthEnabled ? (
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOAuth}
|
||||
disabled={formDisabled}
|
||||
className="text-xs text-[var(--color-text-tertiary)] underline-offset-4 transition-colors hover:text-[var(--color-text-primary)] hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{busy === 'oauth' ? t('login.oauth.opening') : t('login.oauth.altLink')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{localError ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)] px-3 py-2 text-xs text-[var(--color-error)]">
|
||||
{localError}
|
||||
|
||||
@@ -966,6 +966,29 @@ export const en = {
|
||||
// confirm dialogs
|
||||
// ─── HeiCode Login ──────────────────────────────────────
|
||||
'login.subtitle': 'Sign in to start using Heicode',
|
||||
'login.tagline': 'From an idea to a shippable product',
|
||||
'login.signInTarget': 'Sign-in target',
|
||||
'login.footer.heicodeProvidesModels': 'Once signed in, the client uses models provided by Heicode.',
|
||||
|
||||
// ─── High-risk approval dialog ──────────────────────────
|
||||
'approval.title': 'Heicode high-risk approval',
|
||||
'approval.field.task': 'Task',
|
||||
'approval.field.operation': 'Operation',
|
||||
'approval.field.targetResource': 'Target',
|
||||
'approval.field.requestingRole': 'Requesting role',
|
||||
'approval.field.impact': 'Expected impact',
|
||||
'approval.field.credential': 'Credential',
|
||||
'approval.credential.derived': 'Will mint a {minutes}-minute short-lived credential',
|
||||
'approval.credential.notRequired': 'No new credential will be minted',
|
||||
'approval.suggestion.label': 'Heicode suggests',
|
||||
'approval.risk.low': 'Low',
|
||||
'approval.risk.medium': 'Medium',
|
||||
'approval.risk.high': 'High',
|
||||
'approval.risk.critical': 'Critical',
|
||||
'approval.action.reject': 'Reject',
|
||||
'approval.action.postpone': 'Remind me later',
|
||||
'approval.action.approve': 'Approve for {minutes} min',
|
||||
'approval.queue.more': '{count} more pending approvals queued',
|
||||
'login.footnote': 'Heicode talks directly to TaijiAICloud; your API key never leaves this machine.',
|
||||
'login.tags.recommended': 'Recommended',
|
||||
'login.tags.comingSoon': 'Coming soon',
|
||||
|
||||
@@ -968,6 +968,29 @@ export const zh: Record<TranslationKey, string> = {
|
||||
// confirm dialogs
|
||||
// ─── HeiCode 登录 ──────────────────────────────────────
|
||||
'login.subtitle': '登录以开始使用 Heicode',
|
||||
'login.tagline': '从一个想法,到可上线的软件产品',
|
||||
'login.signInTarget': '登录地址',
|
||||
'login.footer.heicodeProvidesModels': '登录后,客户端会使用 Heicode 提供的模型。',
|
||||
|
||||
// ─── 高危审批弹窗 ───────────────────────────────────────
|
||||
'approval.title': 'Heicode 高危操作审批',
|
||||
'approval.field.task': '任务',
|
||||
'approval.field.operation': '请求',
|
||||
'approval.field.targetResource': '资源',
|
||||
'approval.field.requestingRole': '请求角色',
|
||||
'approval.field.impact': '预计影响',
|
||||
'approval.field.credential': '凭证',
|
||||
'approval.credential.derived': '将申请 {minutes} 分钟短期凭证',
|
||||
'approval.credential.notRequired': '不会派生新凭证',
|
||||
'approval.suggestion.label': 'Heicode 建议',
|
||||
'approval.risk.low': '低风险',
|
||||
'approval.risk.medium': '中风险',
|
||||
'approval.risk.high': '高风险',
|
||||
'approval.risk.critical': '严重',
|
||||
'approval.action.reject': '拒绝',
|
||||
'approval.action.postpone': '稍后提醒',
|
||||
'approval.action.approve': '批准 {minutes} 分钟',
|
||||
'approval.queue.more': '另有 {count} 个高危请求等待处理',
|
||||
'login.footnote': 'Heicode 直接连 TaijiAICloud,API Key 仅保存在你这台机器上。',
|
||||
'login.tags.recommended': '推荐',
|
||||
'login.tags.comingSoon': '即将开放',
|
||||
|
||||
@@ -3,8 +3,10 @@ import ReactDOM from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import './theme/globals.css'
|
||||
import { initializeTheme } from './stores/uiStore'
|
||||
import { installApprovalMock } from './components/approval/ApprovalDialog'
|
||||
|
||||
initializeTheme()
|
||||
installApprovalMock()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// desktop/src/stores/approvalStore.ts
|
||||
//
|
||||
// High-risk approval queue. Per
|
||||
// docs/product-package/08-client-guide.md §"高危审批体验" and
|
||||
// docs/product-package/11-product-prototype-wireframes.md §9, the client is
|
||||
// the **only** surface where a human approves dangerous Agnet actions
|
||||
// (production deploys, credential access, large model spend, etc.).
|
||||
//
|
||||
// Slice 6b ships the surface with a mock data path: backend wiring will
|
||||
// arrive when mcp-server / agent-manager publish the approval-stream API.
|
||||
// In the meantime callers (or the developer console) can push approval
|
||||
// payloads onto this queue via `window.__heicodeMockApproval(req)`.
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type ApprovalRiskLevel = 'low' | 'medium' | 'high' | 'critical'
|
||||
|
||||
export type ApprovalRequest = {
|
||||
/** Stable id provided by upstream (or generated locally for mock). */
|
||||
id: string
|
||||
/** User-facing task name, e.g. "小团队任务管理 SaaS". */
|
||||
task_name: string
|
||||
/** What the agent wants to do, e.g. "部署到生产环境". */
|
||||
operation: string
|
||||
/** Target resource handle, e.g. "aks-prod" / "github.com/foo/bar@main". */
|
||||
target_resource: string
|
||||
/** Role requesting the action, e.g. "ops" / "backend". */
|
||||
requesting_role?: string
|
||||
/** Risk classification — drives the dialog tone + button copy. */
|
||||
risk_level: ApprovalRiskLevel
|
||||
/** Short human-readable description of side effects. */
|
||||
impact_summary?: string
|
||||
/** Heicode's recommendation, e.g. "先完成测试环境验证". */
|
||||
heicode_suggestion?: string
|
||||
/** Will approval mint a short-lived credential? Default true for high. */
|
||||
derives_short_lived_credential?: boolean
|
||||
/** TTL of derived credential / approval, in minutes. Default 15. */
|
||||
ttl_minutes?: number
|
||||
/** Unix epoch ms when the request entered the queue. */
|
||||
enqueued_at: number
|
||||
}
|
||||
|
||||
export type ApprovalDecision = 'approve' | 'reject' | 'postpone'
|
||||
|
||||
type ApprovalState = {
|
||||
/** FIFO queue of pending approvals. The dialog displays queue[0]. */
|
||||
queue: ApprovalRequest[]
|
||||
/** History of recent decisions, capped at 50, newest first. */
|
||||
recent: Array<{ id: string; decision: ApprovalDecision; at: number }>
|
||||
|
||||
enqueue: (req: Omit<ApprovalRequest, 'enqueued_at'> & { enqueued_at?: number }) => void
|
||||
decide: (id: string, decision: ApprovalDecision) => void
|
||||
/** Clear the queue (used by logout / for tests). */
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export const useApprovalStore = create<ApprovalState>((set) => ({
|
||||
queue: [],
|
||||
recent: [],
|
||||
|
||||
enqueue: (req) => {
|
||||
const full: ApprovalRequest = {
|
||||
...req,
|
||||
enqueued_at: req.enqueued_at ?? Date.now(),
|
||||
}
|
||||
set((s) => ({
|
||||
// Idempotency: ignore duplicates by id (upstream may retry).
|
||||
queue: s.queue.some((r) => r.id === full.id) ? s.queue : [...s.queue, full],
|
||||
}))
|
||||
},
|
||||
|
||||
decide: (id, decision) =>
|
||||
set((s) => ({
|
||||
queue: s.queue.filter((r) => r.id !== id),
|
||||
recent: [{ id, decision, at: Date.now() }, ...s.recent].slice(0, 50),
|
||||
})),
|
||||
|
||||
clear: () => set({ queue: [] }),
|
||||
}))
|
||||
Reference in New Issue
Block a user