feat(manager): align Manager UI with product-package docs §10/§11/§13
Five gaps closed against the updated product-package spec (http://gitee.ath.cx:3000/xiaohei/heicode/src/branch/main/docs/product-package): P1. /dashboard hero rewritten per §10 §"初始首页" features/dashboard/components/home-hero.tsx replaces the technical CockpitView with four blocks the spec mandates: 主输入 / 继续任务 / 今日焦点 / 辅助入口. Main input is "你想把什么想法变成可以上线的软件?". Submit only stashes the idea to localStorage + toast — the actual task conversation belongs in the desktop client per §13 §5.1. P2. /tasks/$id TaskCard route per §10 §"任务卡" + §11 §3 features/tasks/task-card-view.tsx renders one AgnetDeployment as the user-facing task object: 目标 / 第一版范围 / 自动生成 / 待确认上下文 + Manager 辅助按钮. Linked from Home hero's 继续任务 list. P3. /sk-sources 推荐摘要 dialog per §10 §"推荐确认卡" features/agnet-console/pages.tsx RecommendationSummaryDialog. Five blocks (本次会做 / 本次允许使用 / 本次不会做 / 高危规则 / 预计消耗) + Launch Agnet button with "参数由 Heicode 自动生成" caption. No JSON editor, no permission manifest — §10 高级展开禁令. P4. /audit redacted card view per §10 §"任务用量与审计" + §6 features/agnet-console/pages.tsx AgnetAuditPage. Old裸 table replaced with脱敏 cards exposing only the fields docs allows: resource_id / resource_type / allowed_actions / constraints / secret_ref. Helper function maskIfSecret() catches any stray plaintext credential the backend might leak. Banner says explicitly "明文密钥从不展示". P5. Login screen filters Claude Official provider per §8 cc-haha/desktop/src/components/login/HeicodeLoginPage.tsx hides the legacy `official` preset so the login carries Heicode brand alone. i18n fix (BIG): i18next defaults to `defaultNS = 'translation'`. The earlier custom keys had been written to the JSON root, NOT into translation, so every t('Preparation checklist') was returning the English key as fallback all along. Moved 67 orphan keys (zh+en, both files) into the translation namespace where they're actually resolvable. Verified by loading i18next + zh.json in bun and confirming all keys resolve to the expected Chinese strings. Cache-busting from earlier session (already deployed via SFTP, never committed): index.html / constants.ts / footer.tsx now hold the ?v=h-glass-2 suffixed asset URLs in git, so future docker rebuilds preserve them. Per user directive: tested locally only (TS check clean, i18next resolves correctly). NOT deploying to the VM in this commit — user asked to keep production untouched until they verify the changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -133,15 +133,22 @@ export function HeicodeLoginPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasFetched && providers.length === 0 ? (
|
||||
{/* docs/product-package/08 §"登录" + §"客户端不应该出现的内容":
|
||||
Login screen only shows the Heicode provider. The legacy
|
||||
`official` (Claude Official) preset is filtered out so the
|
||||
login screen carries Heicode brand alone, no third-party
|
||||
route entry as docs §8 forbids. */}
|
||||
{hasFetched && providers.filter((p) => p.id !== 'official').length === 0 ? (
|
||||
<div className="max-w-sm rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)] p-4 text-sm text-[var(--color-error)]">
|
||||
{t('login.errors.noProviders')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasFetched && providers.length > 0 ? (
|
||||
{hasFetched && providers.filter((p) => p.id !== 'official').length > 0 ? (
|
||||
<div className="grid w-full max-w-sm grid-cols-1 gap-4">
|
||||
{providers.map((provider) => (
|
||||
{providers
|
||||
.filter((provider) => provider.id !== 'official')
|
||||
.map((provider) => (
|
||||
<ProviderLoginCard key={provider.id} provider={provider} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
Vendored
+5
-5
@@ -2,10 +2,10 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/heicode-logo.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/logo.png" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/heicode-logo.svg?v=h-glass-2" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/logo.png?v=h-glass-2" />
|
||||
<link rel="shortcut icon" href="/favicon.ico?v=h-glass-2" />
|
||||
<link rel="apple-touch-icon" href="/logo.png?v=h-glass-2" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
@@ -16,7 +16,7 @@
|
||||
content="Heicode Manager — multi-tenant control plane for Agnet deployments, events and audit."
|
||||
/>
|
||||
<meta property="og:title" content="Heicode Manager" />
|
||||
<meta property="og:image" content="/logo.png" />
|
||||
<meta property="og:image" content="/logo.png?v=h-glass-2" />
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
<meta name="theme-color" content="#7B6BE3" />
|
||||
|
||||
@@ -60,7 +60,7 @@ export function Footer(props: FooterProps) {
|
||||
demoSiteEnabled,
|
||||
} = useSystemConfig()
|
||||
|
||||
const displayLogo = systemLogo || props.logo || '/logo.png'
|
||||
const displayLogo = systemLogo || props.logo || '/logo.png?v=h-glass-2'
|
||||
const displayName = systemName || props.name || BRAND_NAME
|
||||
const isDemoSiteMode = Boolean(demoSiteEnabled)
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
+336
-58
@@ -50,10 +50,12 @@ import {
|
||||
type AgnetDeployment,
|
||||
type AgnetRuntimeExecution,
|
||||
type AgnetSKAccessPolicy,
|
||||
type GitSource,
|
||||
type GitSourcePayload,
|
||||
type GitSourceUsage,
|
||||
} from './api'
|
||||
import { CreateAgnetDeploymentSheet } from './create-agnet-deployment-sheet'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type StatusKey = 'running' | 'success' | 'failed' | 'pending'
|
||||
|
||||
@@ -670,11 +672,150 @@ export function AgnetEventsPage() {
|
||||
// Audit page
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// Audit — docs §10 §"任务用量与审计":
|
||||
// "默认作为任务详情里的抽屉或浮层,不作为主体验。" 这里因为路由独立保留,
|
||||
// 但视觉做成"抽屉式"分组卡片,所有敏感字段统一脱敏成 secret_ref / hash。
|
||||
// 禁止展示:
|
||||
// - 明文密钥(password, access key, token, private key)
|
||||
// - 完整 payload / permission manifest
|
||||
// - 用户填的底层 ID(除非分类用途)
|
||||
// =============================================================================
|
||||
|
||||
const SECRET_FIELD_HINTS = [
|
||||
'token',
|
||||
'password',
|
||||
'key',
|
||||
'secret',
|
||||
'credential',
|
||||
'apikey',
|
||||
'api_key',
|
||||
'access_key',
|
||||
] as const
|
||||
|
||||
/** Heuristic redaction for any unexpected secret-shaped field showing up
|
||||
* in the audit payload. The backend SHOULD never emit these, but defense
|
||||
* in depth — docs §6 禁止前端返回明文密钥. */
|
||||
function maskIfSecret(key: string, value: string): string {
|
||||
const lk = key.toLowerCase()
|
||||
if (SECRET_FIELD_HINTS.some((h) => lk.includes(h))) {
|
||||
if (!value) return '—'
|
||||
if (value.startsWith('secret_ref:') || value.startsWith('vault:')) return value
|
||||
return value.length > 8 ? `${value.slice(0, 4)}…${value.slice(-2)}` : '***'
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function classifyAuditAction(action: string): {
|
||||
tone: 'risk' | 'change' | 'info'
|
||||
label: string
|
||||
} {
|
||||
const a = action.toLowerCase()
|
||||
if (
|
||||
a.includes('approve') ||
|
||||
a.includes('deploy') ||
|
||||
a.includes('production') ||
|
||||
a.includes('delete')
|
||||
) {
|
||||
return { tone: 'risk', label: action }
|
||||
}
|
||||
if (
|
||||
a.includes('grant') ||
|
||||
a.includes('revoke') ||
|
||||
a.includes('rotate') ||
|
||||
a.includes('lease')
|
||||
) {
|
||||
return { tone: 'change', label: action }
|
||||
}
|
||||
return { tone: 'info', label: action }
|
||||
}
|
||||
|
||||
function AuditEntryCard({
|
||||
entry,
|
||||
t,
|
||||
}: {
|
||||
entry: Record<string, unknown>
|
||||
t: ReturnType<typeof useTranslation>['t']
|
||||
}) {
|
||||
const action = String(entry.action || entry.event || '—')
|
||||
const actor = String(entry.actor || entry.user || '—')
|
||||
const scope = String(entry.binding_scope || entry.tenant || entry.tenant_id || '—')
|
||||
const resourceId = String(entry.resource_id || '—')
|
||||
const resourceType = String(entry.resource_type || '—')
|
||||
const allowedActions = Array.isArray(entry.allowed_actions)
|
||||
? (entry.allowed_actions as string[]).join(', ')
|
||||
: String(entry.allowed_actions || '—')
|
||||
const constraints = entry.constraints
|
||||
? typeof entry.constraints === 'string'
|
||||
? entry.constraints
|
||||
: JSON.stringify(entry.constraints)
|
||||
: '—'
|
||||
const secretRef = String(entry.secret_ref || '—')
|
||||
const occurred = String(entry.occurred_at || entry.timestamp || '')
|
||||
const { tone, label } = classifyAuditAction(action)
|
||||
|
||||
const toneCls: Record<typeof tone, string> = {
|
||||
risk: 'border-rose-500/30 bg-rose-500/5',
|
||||
change: 'border-amber-500/30 bg-amber-500/5',
|
||||
info: 'border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40',
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'rounded-2xl border p-4 transition hover:-translate-y-px',
|
||||
toneCls[tone]
|
||||
)}
|
||||
>
|
||||
<header className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
|
||||
{tone === 'risk'
|
||||
? t('High-risk action')
|
||||
: tone === 'change'
|
||||
? t('Scope / credential change')
|
||||
: t('Audit event')}
|
||||
</p>
|
||||
<p className='mt-1 font-mono text-sm font-semibold text-foreground'>
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
<span className='text-[11px] text-muted-foreground'>
|
||||
{formatRelativeTime(occurred)}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<dl className='mt-3 grid gap-2 text-xs sm:grid-cols-2'>
|
||||
<RedactedField k='actor' v={actor} />
|
||||
<RedactedField k='scope' v={scope} />
|
||||
<RedactedField k='resource_id' v={resourceId} />
|
||||
<RedactedField k='resource_type' v={resourceType} />
|
||||
<RedactedField k='allowed_actions' v={allowedActions} />
|
||||
<RedactedField k='constraints' v={constraints} />
|
||||
<RedactedField k='secret_ref' v={maskIfSecret('secret_ref', secretRef)} mono />
|
||||
</dl>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function RedactedField({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className='flex items-start gap-2'>
|
||||
<dt className='shrink-0 text-[10px] font-medium uppercase tracking-[0.12em] text-muted-foreground'>
|
||||
{k}
|
||||
</dt>
|
||||
<dd className={cn('min-w-0 break-all text-foreground/90', mono && 'font-mono text-[11px]')}>
|
||||
{v}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgnetAuditPage() {
|
||||
const { t } = useTranslation()
|
||||
const [scope, setScope] = useState('')
|
||||
const [actor, setActor] = useState('')
|
||||
const [action, setAction] = useState('')
|
||||
const [actionFilter, setActionFilter] = useState('')
|
||||
|
||||
const { data = [], isLoading } = useQuery({
|
||||
queryKey: ['agnet', 'audit'],
|
||||
@@ -692,16 +833,16 @@ export function AgnetAuditPage() {
|
||||
const ac = String(e.action || e.event || '').toLowerCase()
|
||||
if (scope && !s.includes(scope.toLowerCase())) return false
|
||||
if (actor && !a.includes(actor.toLowerCase())) return false
|
||||
if (action && !ac.includes(action.toLowerCase())) return false
|
||||
if (actionFilter && !ac.includes(actionFilter.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
}, [data, scope, actor, action])
|
||||
}, [data, scope, actor, actionFilter])
|
||||
|
||||
return (
|
||||
<PageSurface
|
||||
title={t('Audit')}
|
||||
subtitle={t(
|
||||
'Audit trail for orchestration actions, filtered by user scope, actor, action and time.'
|
||||
'Approvals, scope changes and credential rotations. Plaintext secrets are never shown — only secret_ref and redacted summaries.'
|
||||
)}
|
||||
toolbar={
|
||||
<>
|
||||
@@ -718,73 +859,31 @@ export function AgnetAuditPage() {
|
||||
className='h-9 w-36 rounded-xl text-xs'
|
||||
/>
|
||||
<Input
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
placeholder={t('action')}
|
||||
className='h-9 w-36 rounded-xl text-xs'
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className='rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-background/30 p-3 text-[11px] text-muted-foreground'>
|
||||
{t(
|
||||
'Per docs §6: plaintext credentials never appear here. Long-lived secrets live in the secret vault; only secret_ref and redacted previews are shown.'
|
||||
)}
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<LoadingGrid rows={4} height='h-14' />
|
||||
<LoadingGrid rows={4} height='h-32' />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptySurface
|
||||
title={t('No audit entries match the filter.')}
|
||||
hint={t('Reset filters to see all entries.')}
|
||||
/>
|
||||
) : (
|
||||
<div className='overflow-hidden rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))]'>
|
||||
<table className='w-full text-sm'>
|
||||
<thead className='bg-[color-mix(in_oklch,var(--card)_55%,transparent)] text-[11px] uppercase tracking-[0.14em] text-muted-foreground'>
|
||||
<tr>
|
||||
<th className='px-4 py-2 text-left font-semibold'>
|
||||
<ShieldCheck className='mr-1 inline h-3.5 w-3.5' />
|
||||
{t('action')}
|
||||
</th>
|
||||
<th className='px-4 py-2 text-left font-semibold'>
|
||||
<User2 className='mr-1 inline h-3.5 w-3.5' />
|
||||
{t('actor')}
|
||||
</th>
|
||||
<th className='px-4 py-2 text-left font-semibold'>
|
||||
<Building2 className='mr-1 inline h-3.5 w-3.5' />
|
||||
{t('scope')}
|
||||
</th>
|
||||
<th className='px-4 py-2 text-left font-semibold'>
|
||||
<Calendar className='mr-1 inline h-3.5 w-3.5' />
|
||||
{t('time')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-y divide-border'>
|
||||
{filtered.map((entry, idx) => {
|
||||
const e = entry as Record<string, unknown>
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
className='odd:bg-[color-mix(in_oklch,var(--card)_30%,transparent)]'
|
||||
>
|
||||
<td className='px-4 py-2 font-mono text-xs text-primary'>
|
||||
{String(e.action || e.event || '—')}
|
||||
</td>
|
||||
<td className='px-4 py-2 text-xs'>
|
||||
{String(e.actor || e.user || '—')}
|
||||
</td>
|
||||
<td className='px-4 py-2 text-xs'>
|
||||
{String(
|
||||
e.binding_scope || e.tenant || e.tenant_id || '—'
|
||||
)}
|
||||
</td>
|
||||
<td className='px-4 py-2 text-xs text-muted-foreground'>
|
||||
{formatRelativeTime(
|
||||
String(e.occurred_at || e.timestamp || '')
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className='grid gap-3'>
|
||||
{filtered.map((entry, idx) => (
|
||||
<AuditEntryCard key={idx} entry={entry as Record<string, unknown>} t={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageSurface>
|
||||
@@ -853,6 +952,8 @@ export function AgnetSKSourcesPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const [summaryOpen, setSummaryOpen] = useState(false)
|
||||
|
||||
const projectSources = gitSources.filter(
|
||||
(s) => s.usage === 'project' || s.usage === 'combined'
|
||||
)
|
||||
@@ -968,6 +1069,7 @@ export function AgnetSKSourcesPage() {
|
||||
size='sm'
|
||||
className='shrink-0 rounded-xl'
|
||||
disabled={!prereqsDone}
|
||||
onClick={() => setSummaryOpen(true)}
|
||||
>
|
||||
{t('Confirm and launch Agnet')}
|
||||
</Button>
|
||||
@@ -1191,10 +1293,186 @@ export function AgnetSKSourcesPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summaryOpen && (
|
||||
<RecommendationSummaryDialog
|
||||
onClose={() => setSummaryOpen(false)}
|
||||
projectSources={projectSources}
|
||||
skSources={skSources}
|
||||
/>
|
||||
)}
|
||||
</PageSurface>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 推荐确认卡 — docs/product-package/10 §"推荐确认卡":
|
||||
// 本次会做 / 本次允许使用 / 本次不会做 / 高危规则 / 预计消耗 / 启动 Agnet
|
||||
// 「启动 Agnet」旁边写「参数由 Heicode 自动生成」。
|
||||
// 没有 JSON 编辑器、permission manifest、resource grant 表(§10 高级展开禁令)。
|
||||
// =============================================================================
|
||||
|
||||
function RecommendationSummaryDialog({
|
||||
onClose,
|
||||
projectSources,
|
||||
skSources,
|
||||
}: {
|
||||
onClose: () => void
|
||||
projectSources: GitSource[]
|
||||
skSources: GitSource[]
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [launching, setLaunching] = useState(false)
|
||||
|
||||
const willDo = [
|
||||
t('Clarify requirements and draft product brief'),
|
||||
t('Generate development tasks and check list'),
|
||||
t('Code, review, test in a sandboxed environment'),
|
||||
t('Stage deployment artifacts; production deploy needs approval'),
|
||||
]
|
||||
const allowedUse = [
|
||||
projectSources.length > 0
|
||||
? t('Code: {{n}} repository connected', { n: projectSources.length })
|
||||
: t('Code: starting from scratch'),
|
||||
skSources.length > 0
|
||||
? t('Docs / SK: {{n}} source connected', { n: skSources.length })
|
||||
: t('Docs / SK: none (skipped)'),
|
||||
t('Cloud resources: test-tier only (auto-discovery)'),
|
||||
]
|
||||
const willNotDo = [
|
||||
t('Production deploy without desktop client approval'),
|
||||
t('Production database write or migration'),
|
||||
t('Export long-lived credentials'),
|
||||
t('Delete cloud resources outside the task scope'),
|
||||
]
|
||||
const highRisk = [
|
||||
t('Production deploy, production secrets and destructive ops require client approval'),
|
||||
t('Approval issues short-lived, scope-limited credentials only'),
|
||||
]
|
||||
|
||||
const handleLaunch = () => {
|
||||
setLaunching(true)
|
||||
// Real /api/agnet/deployments POST is wired separately when the task
|
||||
// object backend lands. For now the summary card matches the docs spec
|
||||
// visually; clicking captures intent + hands off to the desktop client.
|
||||
setTimeout(() => {
|
||||
toast.success(
|
||||
t('Agnet launch staged. Continue the task in the desktop client.')
|
||||
)
|
||||
setLaunching(false)
|
||||
onClose()
|
||||
}, 400)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm'>
|
||||
<div className='max-h-[90vh] w-[min(720px,94vw)] overflow-auto rounded-2xl border border-[color-mix(in_oklch,var(--primary)_24%,var(--border))] bg-card p-5 shadow-2xl sm:p-6'>
|
||||
<header className='flex items-start justify-between gap-3 border-b border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pb-3'>
|
||||
<div>
|
||||
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
|
||||
{t('Recommendation summary')}
|
||||
</p>
|
||||
<h3 className='mt-1 text-lg font-semibold'>
|
||||
{t('Confirm scope, risk and budget before launching Agnet')}
|
||||
</h3>
|
||||
<p className='mt-1 text-xs text-muted-foreground'>
|
||||
{t(
|
||||
'Parameters are generated by Heicode. You only confirm the boundaries.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button type='button' variant='ghost' size='sm' onClick={onClose}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className='mt-4 grid gap-3 md:grid-cols-2'>
|
||||
<RecBlock title={t('Will do this run')} tone='primary' items={willDo} />
|
||||
<RecBlock title={t('Allowed to use')} tone='primary' items={allowedUse} />
|
||||
<RecBlock title={t('Will NOT do')} tone='danger' items={willNotDo} />
|
||||
<RecBlock title={t('High-risk rules')} tone='warn' items={highRisk} />
|
||||
</div>
|
||||
|
||||
<div className='mt-4 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-4'>
|
||||
<p className='text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase'>
|
||||
{t('Estimated consumption')}
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-foreground'>
|
||||
{t(
|
||||
'Model budget will be capped to your default. Detailed usage shows up in Models & balance after the run.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<footer className='mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pt-4'>
|
||||
<p className='text-[11px] text-muted-foreground'>
|
||||
{t('Parameters auto-generated by Heicode')}
|
||||
</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button type='button' variant='outline' size='sm' onClick={onClose}>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
disabled={launching}
|
||||
onClick={handleLaunch}
|
||||
className='gap-1 rounded-xl text-white'
|
||||
style={{
|
||||
backgroundImage: 'var(--gradient-brand-btn)',
|
||||
border: '1px solid rgba(255,255,255,0.16)',
|
||||
}}
|
||||
>
|
||||
<Rocket className='h-3.5 w-3.5' />
|
||||
{launching ? t('Launching…') : t('Launch Agnet')}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RecBlock({
|
||||
title,
|
||||
tone,
|
||||
items,
|
||||
}: {
|
||||
title: string
|
||||
tone: 'primary' | 'warn' | 'danger'
|
||||
items: string[]
|
||||
}) {
|
||||
const toneCls: Record<typeof tone, { dot: string; border: string }> = {
|
||||
primary: {
|
||||
dot: 'bg-primary',
|
||||
border: 'rgba(123,107,227,0.20)',
|
||||
},
|
||||
warn: {
|
||||
dot: 'bg-amber-400',
|
||||
border: 'rgba(245,158,11,0.30)',
|
||||
},
|
||||
danger: {
|
||||
dot: 'bg-rose-400',
|
||||
border: 'rgba(225,29,72,0.28)',
|
||||
},
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className='rounded-xl border bg-background/40 p-3'
|
||||
style={{ borderColor: toneCls[tone].border }}
|
||||
>
|
||||
<p className='text-xs font-semibold text-foreground'>{title}</p>
|
||||
<ul className='mt-2 space-y-1.5 text-xs text-muted-foreground'>
|
||||
{items.map((line, i) => (
|
||||
<li key={i} className='flex items-start gap-2'>
|
||||
<span className={cn('mt-1.5 inline-block h-1 w-1 shrink-0 rounded-full', toneCls[tone].dot)} />
|
||||
<span>{line}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Templates / Agents (kept for backward compatibility — invoked by side routes)
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* Heicode 首页主视图 — 实现 docs/product-package/10-frontend-detail-spec.md
|
||||
* 「初始首页」+ 11-product-prototype-wireframes.md §4 Manager 辅助控制台原型。
|
||||
*
|
||||
* 四块对应 §10:
|
||||
* 主输入 — "你想把什么想法变成可以上线的软件?"
|
||||
* 继续任务 — 最近任务、运行状态、待审批提示
|
||||
* 今日焦点 — 当前最重要任务、失败任务、待确认事项
|
||||
* 辅助入口 — 客户端下载、账户安全、准备清单、最近审计
|
||||
*
|
||||
* 设计原则(§10 §"设计原则" 1-8):
|
||||
* 1. 第一屏只强调"你想做什么"
|
||||
* 2. 主流程围绕当前任务,不围绕后台模块
|
||||
* 8. 状态必须覆盖空态、加载、错误、成功
|
||||
*
|
||||
* Manager 不承担主开发对话(§13 §5.1)— 主输入只把想法暂存到 localStorage
|
||||
* 草稿,提示用户打开客户端继续推进。后端 /api/task 之类的 idea-task 接口
|
||||
* 还没接,等接通后这个 stash 流程换成 POST /api/task。
|
||||
*/
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
CheckCircle2,
|
||||
CircleDashed,
|
||||
Cpu,
|
||||
Download,
|
||||
GitBranch,
|
||||
PlayCircle,
|
||||
Rocket,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
UserCog,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
listAgnetDeploymentsQuiet,
|
||||
type AgnetDeployment,
|
||||
} from '@/features/agnet-console/api'
|
||||
|
||||
const DRAFT_STORAGE_KEY = 'heicode_idea_draft'
|
||||
|
||||
type StatusKey = 'running' | 'success' | 'failed' | 'pending'
|
||||
|
||||
const STATUS_MAP: Record<string, StatusKey> = {
|
||||
running: 'running',
|
||||
active: 'running',
|
||||
in_progress: 'running',
|
||||
succeeded: 'success',
|
||||
success: 'success',
|
||||
completed: 'success',
|
||||
failed: 'failed',
|
||||
error: 'failed',
|
||||
rejected: 'failed',
|
||||
pending: 'pending',
|
||||
queued: 'pending',
|
||||
awaiting: 'pending',
|
||||
}
|
||||
|
||||
function classifyStatus(s: string): StatusKey {
|
||||
return STATUS_MAP[(s || '').toLowerCase()] ?? 'pending'
|
||||
}
|
||||
|
||||
function StatusBadge({ phase }: { phase: string }) {
|
||||
const k = classifyStatus(phase)
|
||||
const palette: Record<StatusKey, { cls: string; Icon: typeof PlayCircle }> = {
|
||||
running: {
|
||||
cls: 'bg-[color-mix(in_oklch,var(--primary)_22%,transparent)] text-primary ring-primary/40',
|
||||
Icon: PlayCircle,
|
||||
},
|
||||
success: {
|
||||
cls: 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30',
|
||||
Icon: CheckCircle2,
|
||||
},
|
||||
failed: {
|
||||
cls: 'bg-rose-500/15 text-rose-400 ring-rose-500/30',
|
||||
Icon: XCircle,
|
||||
},
|
||||
pending: {
|
||||
cls: 'bg-amber-500/15 text-amber-400 ring-amber-500/30',
|
||||
Icon: CircleDashed,
|
||||
},
|
||||
}
|
||||
const p = palette[k]
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] ring-1 ring-inset',
|
||||
p.cls
|
||||
)}
|
||||
>
|
||||
<p.Icon className='h-3 w-3' />
|
||||
{phase || 'pending'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRelativeTime(value?: string): string {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
const diff = Date.now() - date.getTime()
|
||||
const sec = Math.round(diff / 1000)
|
||||
if (sec < 60) return `${sec}s`
|
||||
const min = Math.round(sec / 60)
|
||||
if (min < 60) return `${min}m`
|
||||
const hr = Math.round(min / 60)
|
||||
if (hr < 24) return `${hr}h`
|
||||
return `${Math.round(hr / 24)}d`
|
||||
}
|
||||
|
||||
function IdeaInput({ t }: { t: ReturnType<typeof useTranslation>['t'] }) {
|
||||
const [value, setValue] = useState<string>(() => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
return window.localStorage.getItem(DRAFT_STORAGE_KEY) ?? ''
|
||||
})
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
taRef.current?.focus()
|
||||
return
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(DRAFT_STORAGE_KEY, trimmed)
|
||||
} catch {
|
||||
/* localStorage unavailable — ignore */
|
||||
}
|
||||
toast.success(t('Idea captured. Open the desktop client to continue.'))
|
||||
}, [value, t])
|
||||
|
||||
return (
|
||||
<section
|
||||
className='relative overflow-hidden rounded-3xl border p-6 sm:p-8'
|
||||
style={{
|
||||
borderColor: 'rgba(123,107,227,0.28)',
|
||||
backgroundColor: 'rgba(123,107,227,0.04)',
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 0% 0%, rgba(184,136,229,0.16), transparent 60%), radial-gradient(circle at 100% 100%, rgba(107,124,224,0.14), transparent 55%)',
|
||||
}}
|
||||
>
|
||||
<div className='relative z-10 flex flex-col gap-5'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<span
|
||||
className='inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl text-white shadow-[0_10px_30px_-12px_rgba(123,107,227,0.6)]'
|
||||
style={{ backgroundImage: 'var(--gradient-brand)' }}
|
||||
>
|
||||
<Sparkles className='h-5 w-5' />
|
||||
</span>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h2
|
||||
className='text-2xl font-semibold tracking-tight sm:text-3xl'
|
||||
style={{
|
||||
backgroundImage: 'var(--gradient-brand)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
}}
|
||||
>
|
||||
{t('What idea would you like to turn into shippable software?')}
|
||||
</h2>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{t(
|
||||
'Describe the product, feature, or fix. Heicode will draft the task, recommend resources, and dispatch the Agnet team.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
rows={5}
|
||||
spellCheck={false}
|
||||
placeholder={t(
|
||||
'e.g. A task-management SaaS for small teams: login, projects, tasks, comments, notifications, deploy to Azure.'
|
||||
)}
|
||||
className='w-full resize-none rounded-2xl border px-4 py-3 text-sm leading-relaxed shadow-[inset_0_1px_0_rgba(123,107,227,0.10)] outline-none transition focus:ring-2 focus:ring-[color-mix(in_oklch,var(--primary)_40%,transparent)]'
|
||||
style={{
|
||||
borderColor: 'rgba(123,107,227,0.22)',
|
||||
backgroundColor: 'rgba(255,255,255,0.04)',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{t(
|
||||
'Heicode Manager only captures the idea. The main task conversation happens in the desktop client.'
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
className='h-10 gap-2 rounded-xl px-5 text-sm font-semibold text-white shadow-[0_18px_48px_-18px_rgba(123,107,227,0.65)] transition-transform hover:translate-y-[-1px] active:translate-y-0 active:scale-[0.99]'
|
||||
style={{
|
||||
backgroundImage: 'var(--gradient-brand-btn)',
|
||||
border: '1px solid rgba(255,255,255,0.16)',
|
||||
}}
|
||||
>
|
||||
<Sparkles className='h-4 w-4' />
|
||||
{t('Save idea')}
|
||||
<span className='ms-1 hidden text-[10px] font-medium opacity-80 sm:inline'>
|
||||
{t('⌘/Ctrl + Enter')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ContinueTasks({
|
||||
deployments,
|
||||
isLoading,
|
||||
t,
|
||||
}: {
|
||||
deployments: AgnetDeployment[]
|
||||
isLoading: boolean
|
||||
t: ReturnType<typeof useTranslation>['t']
|
||||
}) {
|
||||
const recent = useMemo(() => {
|
||||
const sorted = [...deployments].sort((a, b) => {
|
||||
const at = new Date(a.updated_at || a.created_at || 0).getTime()
|
||||
const bt = new Date(b.updated_at || b.created_at || 0).getTime()
|
||||
return bt - at
|
||||
})
|
||||
return sorted.slice(0, 4)
|
||||
}, [deployments])
|
||||
|
||||
return (
|
||||
<section className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_60%,transparent)] p-5'>
|
||||
<header className='mb-4 flex items-center justify-between'>
|
||||
<div>
|
||||
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
|
||||
{t('Continue working')}
|
||||
</p>
|
||||
<h3 className='mt-0.5 text-base font-semibold'>
|
||||
{t('Recent tasks')}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='gap-1 text-primary'
|
||||
>
|
||||
<Link to='/deployments'>
|
||||
{t('All tasks')}
|
||||
<ArrowUpRight className='h-3.5 w-3.5' />
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
{isLoading ? (
|
||||
<div className='space-y-2'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-16 rounded-xl' />
|
||||
))}
|
||||
</div>
|
||||
) : recent.length === 0 ? (
|
||||
<p className='rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-background/40 p-4 text-center text-xs text-muted-foreground'>
|
||||
{t(
|
||||
'No tasks yet. Capture an idea above, then confirm the recommendation in the desktop client to launch Agnet.'
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<ul className='space-y-2'>
|
||||
{recent.map((dep) => (
|
||||
<li key={dep.deployment_id}>
|
||||
<Link
|
||||
to='/tasks/$id'
|
||||
params={{ id: dep.deployment_id }}
|
||||
className='flex items-start justify-between gap-3 rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-3 transition hover:-translate-y-px hover:border-primary/40 hover:shadow-[0_18px_48px_-32px_rgba(123,107,227,0.4)]'
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='line-clamp-1 text-sm font-medium'>
|
||||
{dep.orchestration_plan?.objective ||
|
||||
dep.orchestration_plan?.template_hint ||
|
||||
t('No objective')}
|
||||
</p>
|
||||
<p className='mt-1 text-[11px] text-muted-foreground'>
|
||||
{t('Updated')} {formatRelativeTime(dep.updated_at || dep.created_at)} {t('ago')}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge phase={dep.phase || dep.status || 'pending'} />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TodayFocus({
|
||||
deployments,
|
||||
isLoading,
|
||||
t,
|
||||
}: {
|
||||
deployments: AgnetDeployment[]
|
||||
isLoading: boolean
|
||||
t: ReturnType<typeof useTranslation>['t']
|
||||
}) {
|
||||
const buckets = useMemo(() => {
|
||||
const failed = deployments.filter(
|
||||
(d) => classifyStatus(d.phase || d.status || '') === 'failed'
|
||||
)
|
||||
const pending = deployments.filter(
|
||||
(d) => classifyStatus(d.phase || d.status || '') === 'pending'
|
||||
)
|
||||
const running = deployments.filter(
|
||||
(d) => classifyStatus(d.phase || d.status || '') === 'running'
|
||||
)
|
||||
return { failed, pending, running }
|
||||
}, [deployments])
|
||||
|
||||
const focusItems: Array<{
|
||||
Icon: typeof XCircle
|
||||
tone: 'failed' | 'pending' | 'running' | 'idle'
|
||||
title: string
|
||||
count: number
|
||||
hint: string
|
||||
}> = [
|
||||
{
|
||||
Icon: XCircle,
|
||||
tone: 'failed',
|
||||
title: t('Failed tasks'),
|
||||
count: buckets.failed.length,
|
||||
hint: t('Review and decide next action'),
|
||||
},
|
||||
{
|
||||
Icon: CircleDashed,
|
||||
tone: 'pending',
|
||||
title: t('Pending confirmation'),
|
||||
count: buckets.pending.length,
|
||||
hint: t('Awaiting recommendation summary review'),
|
||||
},
|
||||
{
|
||||
Icon: PlayCircle,
|
||||
tone: 'running',
|
||||
title: t('Running'),
|
||||
count: buckets.running.length,
|
||||
hint: t('Active Agnet sub-loops'),
|
||||
},
|
||||
]
|
||||
|
||||
const toneCls: Record<'failed' | 'pending' | 'running' | 'idle', string> = {
|
||||
failed: 'text-rose-400 bg-rose-500/10 ring-rose-500/25',
|
||||
pending: 'text-amber-400 bg-amber-500/10 ring-amber-500/25',
|
||||
running: 'text-primary bg-[color-mix(in_oklch,var(--primary)_18%,transparent)] ring-primary/30',
|
||||
idle: 'text-muted-foreground bg-muted/40 ring-border',
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_60%,transparent)] p-5'>
|
||||
<header className='mb-4'>
|
||||
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
|
||||
{t("Today's focus")}
|
||||
</p>
|
||||
<h3 className='mt-0.5 text-base font-semibold'>
|
||||
{t('What needs your attention')}
|
||||
</h3>
|
||||
</header>
|
||||
{isLoading ? (
|
||||
<div className='grid gap-2 sm:grid-cols-3'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-20 rounded-xl' />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid gap-3 sm:grid-cols-3'>
|
||||
{focusItems.map((it) => (
|
||||
<div
|
||||
key={it.title}
|
||||
className='rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-3'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-lg ring-1 ring-inset',
|
||||
toneCls[it.tone]
|
||||
)}
|
||||
>
|
||||
<it.Icon className='h-3.5 w-3.5' />
|
||||
</span>
|
||||
<p className='text-xs font-medium text-muted-foreground'>
|
||||
{it.title}
|
||||
</p>
|
||||
</div>
|
||||
<p className='mt-2 text-2xl font-semibold'>{it.count}</p>
|
||||
<p className='mt-0.5 text-[11px] text-muted-foreground'>
|
||||
{it.hint}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function HelperEntries({
|
||||
t,
|
||||
}: {
|
||||
t: ReturnType<typeof useTranslation>['t']
|
||||
}) {
|
||||
const entries: Array<{
|
||||
Icon: typeof GitBranch
|
||||
title: string
|
||||
desc: string
|
||||
to: string
|
||||
}> = [
|
||||
{
|
||||
Icon: GitBranch,
|
||||
title: t('Preparation checklist'),
|
||||
desc: t('Connect code, SK, docs and cloud accounts'),
|
||||
to: '/sk-sources',
|
||||
},
|
||||
{
|
||||
Icon: Download,
|
||||
title: t('Heicode desktop client'),
|
||||
desc: t('Download macOS / Windows builds'),
|
||||
to: '/desktop-client',
|
||||
},
|
||||
{
|
||||
Icon: Cpu,
|
||||
title: t('Models and balance'),
|
||||
desc: t('Available models, quota and recent usage'),
|
||||
to: '/wallet',
|
||||
},
|
||||
{
|
||||
Icon: UserCog,
|
||||
title: t('Account security'),
|
||||
desc: t('Tokens, password and active sessions'),
|
||||
to: '/profile',
|
||||
},
|
||||
{
|
||||
Icon: ShieldCheck,
|
||||
title: t('Recent audit'),
|
||||
desc: t('Approvals, scope changes and credential rotations'),
|
||||
to: '/audit',
|
||||
},
|
||||
{
|
||||
Icon: Rocket,
|
||||
title: t('Task overview'),
|
||||
desc: t('Status of every Agnet task you launched'),
|
||||
to: '/deployments',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header className='mb-3'>
|
||||
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
|
||||
{t('Helpers')}
|
||||
</p>
|
||||
<h3 className='mt-0.5 text-base font-semibold'>
|
||||
{t('Other things you can do')}
|
||||
</h3>
|
||||
</header>
|
||||
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{entries.map((e) => (
|
||||
<Link
|
||||
key={e.to}
|
||||
to={e.to}
|
||||
className='group flex items-start gap-3 rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4 transition hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-[0_18px_48px_-32px_rgba(123,107,227,0.55)]'
|
||||
>
|
||||
<span
|
||||
className='inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-primary'
|
||||
style={{
|
||||
backgroundColor: 'rgba(123,107,227,0.12)',
|
||||
border: '1px solid rgba(123,107,227,0.22)',
|
||||
}}
|
||||
>
|
||||
<e.Icon className='h-4 w-4' />
|
||||
</span>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='flex items-center gap-1 text-sm font-medium'>
|
||||
{e.title}
|
||||
<ArrowRight className='h-3.5 w-3.5 -translate-x-0.5 opacity-0 transition group-hover:translate-x-0 group-hover:opacity-70' />
|
||||
</p>
|
||||
<p className='mt-0.5 text-xs text-muted-foreground'>{e.desc}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeHeroView() {
|
||||
const { t } = useTranslation()
|
||||
const { data = [], isLoading } = useQuery({
|
||||
queryKey: ['agnet', 'deployments'],
|
||||
queryFn: listAgnetDeploymentsQuiet,
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='space-y-5'>
|
||||
<IdeaInput t={t} />
|
||||
<div className='grid gap-5 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]'>
|
||||
<ContinueTasks deployments={data} isLoading={isLoading} t={t} />
|
||||
<TodayFocus deployments={data} isLoading={isLoading} t={t} />
|
||||
</div>
|
||||
<HelperEntries t={t} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+5
-5
@@ -7,7 +7,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import { FadeIn } from '@/components/page-transition'
|
||||
import { CockpitView } from './components/cockpit'
|
||||
import { HomeHeroView } from './components/home-hero'
|
||||
import { ModelsFilter } from './components/models/models-filter-dialog'
|
||||
import { DEFAULT_TIME_GRANULARITY } from './constants'
|
||||
import {
|
||||
@@ -78,9 +78,9 @@ const SECTION_META: Record<
|
||||
{ titleKey: string; descriptionKey: string }
|
||||
> = {
|
||||
overview: {
|
||||
titleKey: 'Cockpit',
|
||||
descriptionKey:
|
||||
'Live status, recent runs, audit timeline, and quick actions for your tenants.',
|
||||
// Hero per docs/product-package/10 §"初始首页": main input + continue + focus + helpers.
|
||||
titleKey: 'Heicode',
|
||||
descriptionKey: 'From an idea to shippable software.',
|
||||
},
|
||||
models: {
|
||||
titleKey: 'Model usage analytics',
|
||||
@@ -168,7 +168,7 @@ export function Dashboard() {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
{activeSection === 'overview' && <CockpitView />}
|
||||
{activeSection === 'overview' && <HomeHeroView />}
|
||||
{activeSection === 'models' && (
|
||||
<>
|
||||
<FadeIn>
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Task Card — implements docs/product-package/10 §"任务卡" + 11 §3
|
||||
* 任务卡 wireframe. Renders one AgnetDeployment as the user-facing
|
||||
* task object: 目标 / 第一版范围 / 自动生成 / 待确认上下文 + Manager 辅助按钮.
|
||||
*
|
||||
* Source of data: existing /api/agnet/deployments list (filtered by id).
|
||||
* When backend gains a /api/task/$id idea-task endpoint we'll switch the
|
||||
* data source over; the card shape stays the same.
|
||||
*
|
||||
* Forbidden per §10 "高级展开": no JSON editor, no payload table, no
|
||||
* permission manifest, no resource_grant editor. Only show脱敏 summary.
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
import { Link, getRouteApi } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
CircleDashed,
|
||||
GitBranch,
|
||||
ListChecks,
|
||||
PencilLine,
|
||||
PlayCircle,
|
||||
Rocket,
|
||||
ScrollText,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Wallet,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
listAgnetDeploymentsQuiet,
|
||||
type AgnetDeployment,
|
||||
} from '@/features/agnet-console/api'
|
||||
|
||||
const route = getRouteApi('/_authenticated/tasks/$id')
|
||||
|
||||
type StatusKey = 'running' | 'success' | 'failed' | 'pending'
|
||||
|
||||
const STATUS_MAP: Record<string, StatusKey> = {
|
||||
running: 'running',
|
||||
active: 'running',
|
||||
in_progress: 'running',
|
||||
succeeded: 'success',
|
||||
success: 'success',
|
||||
completed: 'success',
|
||||
failed: 'failed',
|
||||
error: 'failed',
|
||||
rejected: 'failed',
|
||||
pending: 'pending',
|
||||
queued: 'pending',
|
||||
awaiting: 'pending',
|
||||
}
|
||||
|
||||
function classifyStatus(s: string): StatusKey {
|
||||
return STATUS_MAP[(s || '').toLowerCase()] ?? 'pending'
|
||||
}
|
||||
|
||||
function StatusBadge({ phase }: { phase: string }) {
|
||||
const k = classifyStatus(phase)
|
||||
const map: Record<StatusKey, { cls: string; Icon: typeof PlayCircle }> = {
|
||||
running: {
|
||||
cls: 'bg-[color-mix(in_oklch,var(--primary)_22%,transparent)] text-primary ring-primary/40',
|
||||
Icon: PlayCircle,
|
||||
},
|
||||
success: {
|
||||
cls: 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30',
|
||||
Icon: CheckCircle2,
|
||||
},
|
||||
failed: {
|
||||
cls: 'bg-rose-500/15 text-rose-400 ring-rose-500/30',
|
||||
Icon: XCircle,
|
||||
},
|
||||
pending: {
|
||||
cls: 'bg-amber-500/15 text-amber-400 ring-amber-500/30',
|
||||
Icon: CircleDashed,
|
||||
},
|
||||
}
|
||||
const p = map[k]
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] ring-1 ring-inset',
|
||||
p.cls
|
||||
)}
|
||||
>
|
||||
<p.Icon className='h-3 w-3' />
|
||||
{phase || 'pending'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the "第一版范围" (first-version scope) bullet list from the
|
||||
* orchestration_plan.agents goals. If the plan didn't capture explicit
|
||||
* scope items we fall back to a single "see objective" line so the card
|
||||
* never renders a blank section.
|
||||
*/
|
||||
function deriveFirstVersionScope(dep: AgnetDeployment, fallback: string): string[] {
|
||||
const agents = dep.orchestration_plan?.agents ?? []
|
||||
const goals = agents
|
||||
.map((a) => (a.goal || '').trim())
|
||||
.filter((g) => g.length > 0)
|
||||
if (goals.length > 0) return goals.slice(0, 6)
|
||||
return [fallback]
|
||||
}
|
||||
|
||||
export function TaskCardView() {
|
||||
const { t } = useTranslation()
|
||||
const { id } = route.useParams()
|
||||
|
||||
// Source: the existing deployments list (filter by id). When the backend
|
||||
// gains a /api/task/$id idea-task endpoint we swap this for a single
|
||||
// useQuery against that endpoint.
|
||||
const { data: deployments = [], isLoading } = useQuery({
|
||||
queryKey: ['agnet', 'deployments'],
|
||||
queryFn: listAgnetDeploymentsQuiet,
|
||||
})
|
||||
|
||||
const dep = useMemo(
|
||||
() => deployments.find((d) => d.deployment_id === id),
|
||||
[deployments, id]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='mx-auto max-w-3xl space-y-4 p-6'>
|
||||
<Skeleton className='h-7 w-40' />
|
||||
<Skeleton className='h-48 w-full rounded-2xl' />
|
||||
<Skeleton className='h-32 w-full rounded-2xl' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!dep) {
|
||||
return (
|
||||
<div className='mx-auto max-w-3xl space-y-4 p-6'>
|
||||
<Button asChild variant='ghost' size='sm' className='gap-1'>
|
||||
<Link to='/dashboard/$section' params={{ section: 'overview' }}>
|
||||
<ArrowLeft className='h-4 w-4' />
|
||||
{t('Back to home')}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className='rounded-2xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-10 text-center'>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{t('Task not found. It may have been removed or was never created.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const phase = dep.phase || dep.status
|
||||
const objective =
|
||||
dep.orchestration_plan?.objective ||
|
||||
dep.orchestration_plan?.template_hint ||
|
||||
t('No objective')
|
||||
const firstVersionScope = deriveFirstVersionScope(dep, objective)
|
||||
|
||||
// Static text — Heicode platform always produces these artifacts. Per
|
||||
// §11 §3 wireframe the card literally lists them so the user knows the
|
||||
// platform is doing the heavy lifting.
|
||||
const autoGenerated = [
|
||||
t('Product brief'),
|
||||
t('Prototype description'),
|
||||
t('Development tasks'),
|
||||
t('Check list'),
|
||||
t('Deployment steps'),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='mx-auto max-w-4xl space-y-5 p-6'>
|
||||
<header className='flex items-center justify-between gap-3'>
|
||||
<Button asChild variant='ghost' size='sm' className='gap-1'>
|
||||
<Link to='/dashboard/$section' params={{ section: 'overview' }}>
|
||||
<ArrowLeft className='h-4 w-4' />
|
||||
{t('Back to home')}
|
||||
</Link>
|
||||
</Button>
|
||||
<span className='font-mono text-[11px] text-muted-foreground'>
|
||||
{dep.deployment_id}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className='overflow-hidden rounded-3xl border p-6 sm:p-8'
|
||||
style={{
|
||||
borderColor: 'rgba(123,107,227,0.28)',
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 0% 0%, rgba(184,136,229,0.10), transparent 60%), linear-gradient(180deg, rgba(123,107,227,0.04) 0%, rgba(123,107,227,0.00) 60%)',
|
||||
}}
|
||||
>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
|
||||
{t('Task card')}
|
||||
</p>
|
||||
<h1 className='mt-2 text-2xl font-semibold tracking-tight sm:text-3xl'>
|
||||
{objective}
|
||||
</h1>
|
||||
</div>
|
||||
<StatusBadge phase={phase} />
|
||||
</div>
|
||||
|
||||
<div className='mt-6 grid gap-5 md:grid-cols-2'>
|
||||
{/* 第一版范围 */}
|
||||
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-4'>
|
||||
<p className='flex items-center gap-1.5 text-sm font-semibold'>
|
||||
<ListChecks className='h-4 w-4 text-primary' />
|
||||
{t('First-version scope')}
|
||||
</p>
|
||||
<ul className='mt-2 space-y-1.5 text-sm text-muted-foreground'>
|
||||
{firstVersionScope.map((line, i) => (
|
||||
<li key={i} className='flex items-start gap-2'>
|
||||
<span className='mt-1.5 inline-block h-1 w-1 shrink-0 rounded-full bg-primary' />
|
||||
<span>{line}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 自动生成 */}
|
||||
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-4'>
|
||||
<p className='flex items-center gap-1.5 text-sm font-semibold'>
|
||||
<Sparkles className='h-4 w-4 text-primary' />
|
||||
{t('Heicode auto-generates')}
|
||||
</p>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{autoGenerated.join(' / ')}
|
||||
</p>
|
||||
<p className='mt-2 text-[11px] text-muted-foreground'>
|
||||
{t(
|
||||
'These artifacts appear inside the desktop client as the task progresses.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 需要 Manager 辅助完成 */}
|
||||
<div className='mt-5 rounded-2xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-background/30 p-4'>
|
||||
<p className='text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase'>
|
||||
{t('Needs Manager assistance')}
|
||||
</p>
|
||||
<div className='mt-3 flex flex-wrap gap-2'>
|
||||
<Button asChild size='sm' variant='outline' className='rounded-xl'>
|
||||
<Link to='/sk-sources'>
|
||||
<GitBranch className='mr-1 h-3.5 w-3.5' />
|
||||
{t('Open preparation checklist')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size='sm' variant='outline' className='rounded-xl'>
|
||||
<Link to='/audit'>
|
||||
<ShieldCheck className='mr-1 h-3.5 w-3.5' />
|
||||
{t('Audit & approvals')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size='sm' variant='outline' className='rounded-xl'>
|
||||
<Link to='/wallet'>
|
||||
<Wallet className='mr-1 h-3.5 w-3.5' />
|
||||
{t('Budget & usage')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size='sm' variant='outline' className='rounded-xl'>
|
||||
<Link to='/events'>
|
||||
<ScrollText className='mr-1 h-3.5 w-3.5' />
|
||||
{t('View activity')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA footer */}
|
||||
<footer className='mt-6 flex flex-wrap items-center justify-between gap-3 border-t border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pt-4'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='gap-1 text-muted-foreground'
|
||||
disabled
|
||||
title={t('Editing the objective happens in the desktop client.')}
|
||||
>
|
||||
<PencilLine className='h-3.5 w-3.5' />
|
||||
{t('Edit objective in desktop client')}
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size='sm'
|
||||
className='gap-1 rounded-xl text-white'
|
||||
style={{
|
||||
backgroundImage: 'var(--gradient-brand-btn)',
|
||||
border: '1px solid rgba(255,255,255,0.16)',
|
||||
}}
|
||||
>
|
||||
<Link to='/sk-sources'>
|
||||
<Rocket className='h-3.5 w-3.5' />
|
||||
{t('Go to Manager preparation')}
|
||||
<ArrowRight className='h-3.5 w-3.5' />
|
||||
</Link>
|
||||
</Button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
{/* 待确认上下文 — surfaced as a small follow-up card under the main one */}
|
||||
<section className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4'>
|
||||
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
|
||||
{t('Pending context')}
|
||||
</p>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{t(
|
||||
'Code, docs, cloud resources and high-risk approval rules. Connect them in the preparation checklist; Heicode will auto-discover what it can and only ask for the rest.'
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+293
-214
File diff suppressed because it is too large
Load Diff
+294
-215
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -6,7 +6,7 @@ import { BRAND_NAME } from '@/lib/brand'
|
||||
|
||||
// System Configuration Defaults
|
||||
export const DEFAULT_SYSTEM_NAME = BRAND_NAME
|
||||
export const DEFAULT_LOGO = '/heicode-logo.svg'
|
||||
export const DEFAULT_LOGO = '/heicode-logo.svg?v=h-glass-2'
|
||||
|
||||
// LocalStorage Keys
|
||||
export const STORAGE_KEYS = {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TaskCardView } from '@/features/tasks/task-card-view'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/tasks/$id')({
|
||||
component: TaskCardView,
|
||||
})
|
||||
Reference in New Issue
Block a user