feat: route Heicode desktop login through code.xinghanlab.com Manager
Three concrete pieces: 1. Provider preset taijiaicloud now points at https://code.xinghanlab.com instead of the old api.taijiaicloud.com. Together with the stock resolveOAuthConfig fallback (<baseUrl>/heicode/oauth/authorize), this flips oauthEnabled on for the login card and turns the existing browser-redirect bridge into the default flow. Card name + promo updated to reflect that this is "log in via Heicode Manager". 2. loginAndActivate softens its model probe. /v1/models is best-effort: only hard 401/403 auth failures abort login. 5xx / panics / empty lists fall back to preset.defaultModels so the user lands inside the app even if the gateway transiently misbehaves; they can re-pick models from Settings later. 3. heicode_oauth.go fallback page: /login → /sign-in (matches the actual SPA route), title/copy de-branded from "HeiCode/新 API 控制台" to plain "Heicode 控制台". Also picks up the prior unstaged Windows polish: WindowControls (min/ max/close + drag region) on the login screen, ProviderLoginCard + globals.css refinements that landed in earlier MSI builds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,8 @@
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:event:default",
|
||||
"shell:allow-open",
|
||||
{
|
||||
"identifier": "shell:allow-execute",
|
||||
|
||||
@@ -12,14 +12,16 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "HeiCode",
|
||||
"title": "Heicode",
|
||||
"width": 1440,
|
||||
"height": 960,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"decorations": true,
|
||||
"decorations": false,
|
||||
"transparent": false,
|
||||
"acceptFirstMouse": true
|
||||
"acceptFirstMouse": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { WindowControls } from './WindowControls'
|
||||
|
||||
const isMacHost = typeof navigator !== 'undefined'
|
||||
&& /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || '')
|
||||
|
||||
export function TitleBar() {
|
||||
const { activeView, setActiveView } = useUIStore()
|
||||
@@ -10,12 +14,12 @@ export function TitleBar() {
|
||||
className="h-[var(--titlebar-height)] flex items-center border-b border-[var(--color-border)] bg-[var(--color-surface)] select-none"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
{/* macOS traffic light spacer */}
|
||||
<div className="w-[78px] flex-shrink-0" data-tauri-drag-region />
|
||||
{/* macOS traffic-light spacer (native overlay buttons appear here) */}
|
||||
{isMacHost ? <div className="w-[78px] flex-shrink-0" data-tauri-drag-region /> : <div className="w-3" data-tauri-drag-region />}
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 mr-4" data-tauri-drag-region>
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--color-brand)] uppercase">Heicode</span>
|
||||
<span className="text-xs font-bold tracking-[0.22em] text-[var(--color-brand)] uppercase">Heicode</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
@@ -57,11 +61,12 @@ export function TitleBar() {
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{/* Right: Settings */}
|
||||
<div className="flex items-center gap-2 mr-4">
|
||||
<button className="p-1.5 rounded-[var(--radius-md)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] transition-colors">
|
||||
{/* Right: Settings + Window controls */}
|
||||
<div className="flex items-center h-full">
|
||||
<button className="p-1.5 mr-2 rounded-[var(--radius-md)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] transition-colors">
|
||||
<span className="material-symbols-outlined text-[18px]">settings</span>
|
||||
</button>
|
||||
<WindowControls />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
//
|
||||
// 全屏登录页 — 当 Heicode 检测到尚未登录时由 AppShell 渲染。
|
||||
// 仅 1 个登录入口:TaijiAICloud。
|
||||
//
|
||||
// 顶部自绘 36px 高的 chrome(drag region + WindowControls),因为
|
||||
// tauri.conf.json 已设 decorations:false 以摆脱 Windows 系统灰边框。
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore'
|
||||
import { ProviderLoginCard } from './ProviderLoginCard'
|
||||
import { WindowControls } from '../layout/WindowControls'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export function HeicodeLoginPage() {
|
||||
@@ -30,47 +34,69 @@ export function HeicodeLoginPage() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-screen w-screen flex-col items-center justify-center overflow-auto bg-[var(--color-surface)] px-6 py-10"
|
||||
className="relative flex h-screen w-screen flex-col bg-[var(--color-background)]"
|
||||
data-testid="heicode-login-page"
|
||||
>
|
||||
<div className="mb-10 text-center">
|
||||
<h1 className="text-4xl font-bold tracking-[0.18em] uppercase text-[var(--color-brand)]">
|
||||
Heicode
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-[var(--color-text-secondary)]">
|
||||
{t('login.subtitle')}
|
||||
</p>
|
||||
{/* Chrome strip — drag region + window controls */}
|
||||
<div
|
||||
className="flex h-9 w-full items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface)]"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4" data-tauri-drag-region>
|
||||
<span className="text-[10px] font-bold tracking-[0.22em] uppercase text-[var(--color-brand)]">
|
||||
Heicode
|
||||
</span>
|
||||
</div>
|
||||
<WindowControls />
|
||||
</div>
|
||||
|
||||
{!hasFetched && isLoading ? (
|
||||
<div className="text-sm text-[var(--color-text-tertiary)]">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Subtle radial backdrop for premium feel */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 50% 30%, rgba(197,165,114,0.06), transparent 55%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasFetched && providers.length === 0 ? (
|
||||
<div className="rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface-elevated,#fff)] p-4 text-sm text-[var(--color-error)]">
|
||||
{t('login.errors.noProviders')}
|
||||
{/* 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">
|
||||
<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>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasFetched && providers.length > 0 ? (
|
||||
<div className="grid w-full max-w-md grid-cols-1 gap-6">
|
||||
{providers.map((provider) => (
|
||||
<ProviderLoginCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!hasFetched && isLoading ? (
|
||||
<div className="text-sm text-[var(--color-text-tertiary)]">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="mt-6 max-w-xl rounded-md bg-[var(--color-error-bg,rgba(220,38,38,.06))] px-4 py-3 text-sm text-[var(--color-error)]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{hasFetched && providers.length === 0 ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-sm text-[var(--color-error)]">
|
||||
{t('login.errors.noProviders')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasFetched && providers.length > 0 ? (
|
||||
<div className="grid w-full max-w-md grid-cols-1 gap-6">
|
||||
{providers.map((provider) => (
|
||||
<ProviderLoginCard key={provider.id} provider={provider} />
|
||||
))}
|
||||
</div>
|
||||
) : 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)]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,36 +68,34 @@ export function ProviderLoginCard({ provider }: Props) {
|
||||
const local = isLocalBaseUrl(provider.baseUrl)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-[var(--radius-lg)] border border-[var(--color-border-separator)] bg-[var(--color-surface)] p-6 shadow-[var(--shadow-card)]">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
<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>
|
||||
{provider.oauthEnabled ? (
|
||||
<span className="rounded-full bg-[var(--color-success-bg,rgba(22,163,74,.1))] px-2 py-0.5 text-xs text-[var(--color-success)]">
|
||||
<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>
|
||||
) : (
|
||||
<span className="rounded-full bg-[var(--color-surface-muted,#f0f0f0)] px-2 py-0.5 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">
|
||||
{t('login.tags.comingSoon')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Always-visible baseUrl indicator. Helps the user immediately tell
|
||||
whether this card is pointing at the public TaijiAICloud endpoint
|
||||
or has been overridden to a local heicode dev gateway. */}
|
||||
{/* baseUrl indicator */}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]"
|
||||
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>{t('login.baseUrl.label')}</span>
|
||||
<code className="rounded bg-[var(--color-surface-muted,#f0f0f0)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-text-secondary)] break-all">
|
||||
<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 bg-[var(--color-warning-bg,rgba(217,119,6,.1))] px-2 py-0.5 text-[10px] uppercase tracking-wide text-[var(--color-warning)]"
|
||||
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')}
|
||||
data-testid={`heicode-login-card-local-tag-${provider.id}`}
|
||||
>
|
||||
@@ -107,7 +105,7 @@ export function ProviderLoginCard({ provider }: Props) {
|
||||
</div>
|
||||
|
||||
{provider.promoText ? (
|
||||
<p className="text-sm text-[var(--color-text-secondary)] leading-relaxed">
|
||||
<p className="text-sm leading-relaxed text-[var(--color-text-secondary)]">
|
||||
{provider.promoText}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -117,7 +115,7 @@ export function ProviderLoginCard({ provider }: Props) {
|
||||
type="button"
|
||||
onClick={handleOAuth}
|
||||
disabled={!provider.oauthEnabled || isLoggingIn || busy !== null}
|
||||
className="rounded-md bg-[image:var(--gradient-btn-primary)] px-4 py-2.5 text-sm text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-opacity hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="group/btn relative overflow-hidden 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 === 'oauth'
|
||||
? t('login.oauth.opening')
|
||||
@@ -133,7 +131,7 @@ export function ProviderLoginCard({ provider }: Props) {
|
||||
</div>
|
||||
|
||||
{localError ? (
|
||||
<div className="rounded-md bg-[var(--color-error-bg,rgba(220,38,38,.06))] px-3 py-2 text-xs text-[var(--color-error)]">
|
||||
<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}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
|
||||
/* Border aliases */
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--color-border-hi: rgba(255, 255, 255, 0.14);
|
||||
--color-border-focus: var(--color-primary);
|
||||
--color-border-separator: rgba(255, 255, 255, 0.06);
|
||||
|
||||
@@ -337,6 +338,7 @@
|
||||
--sidebar-panel-bg-image: none;
|
||||
|
||||
--color-border: rgba(0, 0, 0, 0.08);
|
||||
--color-border-hi: rgba(0, 0, 0, 0.14);
|
||||
--color-border-focus: var(--color-primary);
|
||||
--color-border-separator: rgba(0, 0, 0, 0.06);
|
||||
|
||||
@@ -637,11 +639,21 @@ button, input, textarea, select, a, [role="button"] {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
/* Scrollbar — thin, subtle, premium */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--color-outline); border-radius: 9999px; opacity: 0.3; }
|
||||
::-webkit-scrollbar-thumb:hover { opacity: 0.6; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--color-outline) 30%, transparent);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
transition: background 160ms ease;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--color-outline) 60%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-corner { background: transparent; }
|
||||
|
||||
.sidebar-scroll-area {
|
||||
scrollbar-width: thin;
|
||||
|
||||
@@ -288,7 +288,14 @@ async function loginAndActivate(
|
||||
apiFormat: preset.apiFormat,
|
||||
})
|
||||
|
||||
if (probe.models.length === 0) {
|
||||
// Soft probe: if /v1/models is unreachable / panics / returns empty, fall back
|
||||
// to the preset's defaultModels so the user can still complete login. They can
|
||||
// re-pick models later from Settings once the gateway returns a proper list.
|
||||
// Hard auth failures (401/403) still surface as model probe errors via fetchProviderModels;
|
||||
// we treat any other failure (5xx, panic, network) as "skippable".
|
||||
const isHardAuthError =
|
||||
probe.error?.includes('HTTP 401') || probe.error?.includes('HTTP 403')
|
||||
if (probe.models.length === 0 && isHardAuthError) {
|
||||
throw ApiError.badRequest(
|
||||
`校验失败: ${probe.error ?? '该 API Key 在 ' + preset.name + ' 上无可用模型'}`,
|
||||
)
|
||||
@@ -299,7 +306,10 @@ async function loginAndActivate(
|
||||
const availableIds = new Set(probe.models.map(m => m.id))
|
||||
const pick = (preferred: string): string => {
|
||||
if (preferred && availableIds.has(preferred)) return preferred
|
||||
return probe.models[0]?.id ?? preferred
|
||||
if (probe.models.length > 0) return probe.models[0].id
|
||||
// No live model list — use the preset default verbatim. The user can change
|
||||
// it later once the gateway is healthy.
|
||||
return preferred
|
||||
}
|
||||
const models = {
|
||||
main: pick(preset.defaultModels.main),
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
},
|
||||
{
|
||||
"id": "taijiaicloud",
|
||||
"name": "TaijiAICloud",
|
||||
"baseUrl": "https://api.taijiaicloud.com",
|
||||
"name": "Heicode",
|
||||
"baseUrl": "https://code.xinghanlab.com",
|
||||
"apiFormat": "anthropic",
|
||||
"defaultModels": {
|
||||
"main": "claude-sonnet-4-6",
|
||||
@@ -25,9 +25,9 @@
|
||||
"opus": "claude-opus-4-6"
|
||||
},
|
||||
"needsApiKey": true,
|
||||
"websiteUrl": "https://api.taijiaicloud.com",
|
||||
"apiKeyUrl": "https://api.taijiaicloud.com/dashboard/keys",
|
||||
"promoText": "TaijiAICloud 是 HeiCode 推荐的国内 LLM 网关,原生支持 Anthropic /v1/messages 协议,覆盖 GPT / Claude / Gemini 等主流模型。",
|
||||
"websiteUrl": "https://code.xinghanlab.com",
|
||||
"apiKeyUrl": "https://code.xinghanlab.com/dashboard/keys",
|
||||
"promoText": "通过 Heicode Manager 登录,自动配置模型与额度。",
|
||||
"featured": true,
|
||||
"defaultEnv": {
|
||||
"API_TIMEOUT_MS": "3000000",
|
||||
|
||||
@@ -152,14 +152,14 @@ func renderHeicodeLoginRequired(c *gin.Context, state, redirectURI, providerID s
|
||||
url.QueryEscape(redirectURI),
|
||||
url.QueryEscape(providerID),
|
||||
)
|
||||
loginURL := "/login"
|
||||
loginURL := "/sign-in"
|
||||
|
||||
body := fmt.Sprintf(`<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>HeiCode 登录授权</title>
|
||||
<title>Heicode 登录授权</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
html,body { margin:0; padding:0; }
|
||||
@@ -175,11 +175,11 @@ func renderHeicodeLoginRequired(c *gin.Context, state, redirectURI, providerID s
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>请先登录新 API 控制台</h1>
|
||||
<p>HeiCode 客户端正在等待你完成授权。请在新打开的页面中登录到 <strong>%s</strong>,登录成功后本页会自动跳转回 HeiCode。</p>
|
||||
<h1>请先登录 Heicode 控制台</h1>
|
||||
<p>Heicode 桌面端正在等待你完成授权。请在新打开的页面中登录到 <strong>%s</strong>,登录成功后本页会自动跳转回客户端。</p>
|
||||
<a id="goLogin" class="btn" href="%s" target="_blank" rel="noopener">打开登录页</a>
|
||||
<p class="muted">如果浏览器没有自动跳转,请手动在登录后回到此页面,或刷新本页。</p>
|
||||
<p class="muted">提供方:<code>%s</code></p>
|
||||
<p class="muted">租户:<code>%s</code></p>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
|
||||
Reference in New Issue
Block a user