Commit c9ab97c3 authored by edy's avatar edy

fix(ui): scope settings actions

parent 68462773
......@@ -71,6 +71,15 @@ import {
} from "./features/shell/startupStatus";
import { SettingsPanels } from "./features/settings/SettingsPanels";
import { SettingsView } from "./features/settings/SettingsView";
import {
getHasPendingSettingsChange,
getResetCopywritingSettingsDrafts,
getResetDigitalHumanSettingsDrafts,
getResetDouyinRuntimeSettingsDrafts,
getResetImageSettingsDrafts,
getResetVideoSettingsDrafts,
getResetXhsFeishuSettingsDrafts
} from "./features/settings/settingsDrafts";
import { useSaveSettings } from "./features/settings/useSaveSettings";
import { useSettingsState } from "./features/settings/useSettingsState";
import { useSmokeActionHandlers } from "./features/smoke/useSmokeActionHandlers";
......@@ -397,8 +406,7 @@ export default function App() {
saving,
setSaving,
hasPendingLobsterKey,
hasPendingXhsFeishuConfig,
hasPendingModelKeys
hasPendingXhsFeishuConfig
} = useSettingsState(config);
const {
messageTraces,
......@@ -411,8 +419,13 @@ export default function App() {
} = useMessageTraces();
const {
saveConfig,
saveWorkspaceDirectory,
restoreWorkspaceDirectory,
saveLobsterKey,
saveXhsFeishuConfig,
saveCopywritingConfig,
saveImageConfig,
saveVideoConfig,
saveDigitalHumanConfig,
saveDouyinRuntimeConfig,
pickWorkspaceDirectory
} = useSaveSettings({
config,
......@@ -491,6 +504,76 @@ export default function App() {
const savedWorkspacePath = config?.workspacePath ?? "";
const displayedWorkspacePath = workspacePathDraft.trim() || savedWorkspacePath || ui.none;
const hasPendingWorkspacePathChange = Boolean(config && workspacePathDraft.trim() && workspacePathDraft.trim() !== savedWorkspacePath);
const hasPendingBasicConfig = hasPendingLobsterKey || hasPendingWorkspacePathChange;
const hasPendingCopywritingConfig = Boolean(copywritingModelApiKeyDraft.trim());
const hasPendingImageConfig = Boolean(imageModelApiKeyDraft.trim());
const hasPendingVideoConfig = Boolean(videoModelApiKeyDraft.trim());
const hasPendingDigitalHumanConfig = Boolean(
digitalHumanVolcAccessKeyDraft.trim()
|| digitalHumanVolcSecretKeyDraft.trim()
|| digitalHumanQiniuAccessKeyDraft.trim()
|| digitalHumanQiniuSecretKeyDraft.trim()
);
const hasPendingDouyinRuntimeConfig = Boolean(
videoAnalyzerBaseUrlDraft.trim() !== (config?.douyinRuntimeConfig.videoAnalyzer.baseUrl ?? "").trim()
|| videoAnalyzerModelIdDraft.trim() !== (config?.douyinRuntimeConfig.videoAnalyzer.modelId ?? "").trim()
|| videoAnalyzerApiKeyDraft.trim()
|| replicationBriefBaseUrlDraft.trim() !== (config?.douyinRuntimeConfig.replicationBrief.baseUrl ?? "").trim()
|| replicationBriefModelIdDraft.trim() !== (config?.douyinRuntimeConfig.replicationBrief.modelId ?? "").trim()
|| replicationBriefApiKeyDraft.trim()
|| vectcutBaseUrlDraft.trim() !== (config?.douyinRuntimeConfig.vectcut.baseUrl ?? "").trim()
|| vectcutFileBaseUrlDraft.trim() !== (config?.douyinRuntimeConfig.vectcut.fileBaseUrl ?? "").trim()
|| vectcutApiKeyDraft.trim()
);
const hasPendingSettingsChange = getHasPendingSettingsChange({
hasPendingBasicConfig,
hasPendingXhsFeishuConfig,
hasPendingCopywritingConfig,
hasPendingImageConfig,
hasPendingVideoConfig,
hasPendingDigitalHumanConfig,
hasPendingDouyinRuntimeConfig
});
void hasPendingSettingsChange;
const resetXhsFeishuSettingsDrafts = useCallback(() => {
const drafts = getResetXhsFeishuSettingsDrafts();
setXhsFeishuAppIdDraft(drafts.xhsFeishuAppId);
setXhsFeishuAppSecretDraft(drafts.xhsFeishuAppSecret);
setXhsFeishuAppTokenDraft(drafts.xhsFeishuAppToken);
setXhsFeishuTableIdDraft(drafts.xhsFeishuTableId);
}, []);
const resetCopywritingSettingsDrafts = useCallback(() => {
setCopywritingModelApiKeyDraft(getResetCopywritingSettingsDrafts().copywritingModelApiKey);
}, []);
const resetImageSettingsDrafts = useCallback(() => {
setImageModelApiKeyDraft(getResetImageSettingsDrafts().imageModelApiKey);
}, []);
const resetVideoSettingsDrafts = useCallback(() => {
setVideoModelApiKeyDraft(getResetVideoSettingsDrafts().videoModelApiKey);
}, []);
const resetDigitalHumanSettingsDrafts = useCallback(() => {
const drafts = getResetDigitalHumanSettingsDrafts();
setDigitalHumanVolcAccessKeyDraft(drafts.digitalHumanVolcAccessKey);
setDigitalHumanVolcSecretKeyDraft(drafts.digitalHumanVolcSecretKey);
setDigitalHumanQiniuAccessKeyDraft(drafts.digitalHumanQiniuAccessKey);
setDigitalHumanQiniuSecretKeyDraft(drafts.digitalHumanQiniuSecretKey);
}, []);
const resetDouyinRuntimeSettingsDrafts = useCallback(() => {
if (!config) {
return;
}
const drafts = getResetDouyinRuntimeSettingsDrafts(config);
setVideoAnalyzerBaseUrlDraft(drafts.videoAnalyzerBaseUrl);
setVideoAnalyzerModelIdDraft(drafts.videoAnalyzerModelId);
setVideoAnalyzerApiKeyDraft(drafts.videoAnalyzerApiKey);
setReplicationBriefBaseUrlDraft(drafts.replicationBriefBaseUrl);
setReplicationBriefModelIdDraft(drafts.replicationBriefModelId);
setReplicationBriefApiKeyDraft(drafts.replicationBriefApiKey);
setVectcutBaseUrlDraft(drafts.vectcutBaseUrl);
setVectcutFileBaseUrlDraft(drafts.vectcutFileBaseUrl);
setVectcutApiKeyDraft(drafts.vectcutApiKey);
}, [config]);
const startupProgress = getStartupProgress(startupPhase);
const startupCurtainStatus = getStartupCurtainStatus(startupPhase, chatLaunchState);
const startupCurtainFootnote = getStartupCurtainFootnote(chatLaunchState);
......@@ -1230,7 +1313,7 @@ export default function App() {
saving,
bindingLabel: ui.binding,
onLobsterKeyChange: setLobsterKeyDraft,
onSave: () => void saveConfig({ lobsterKey: lobsterKeyDraft })
onSave: () => void saveLobsterKey()
},
hasExpertProjects: Boolean(expertPageProjects.length),
noExpertsLabel: expertsPageCopy.noExperts,
......@@ -1306,12 +1389,16 @@ export default function App() {
config,
workspaceApiKeyConfigured: Boolean(workspace?.apiKeyConfigured),
displayedWorkspacePath,
hasPendingWorkspacePathChange,
saving,
hasPendingLobsterKey,
hasPendingWorkspacePathChange,
hasPendingXhsFeishuConfig,
hasPendingModelKeys,
saving,
labels: { saving: ui.saving, save: ui.save, export: ui.export },
hasPendingCopywritingConfig,
hasPendingImageConfig,
hasPendingVideoConfig,
hasPendingDigitalHumanConfig,
hasPendingDouyinRuntimeConfig,
labels: { export: ui.export },
drafts: {
lobsterKey: lobsterKeyDraft,
xhsFeishuAppId: xhsFeishuAppIdDraft,
......@@ -1358,9 +1445,19 @@ export default function App() {
setVectcutFileBaseUrl: setVectcutFileBaseUrlDraft,
setVectcutApiKey: setVectcutApiKeyDraft
},
saveConfig,
saveWorkspaceDirectory,
restoreWorkspaceDirectory,
onSaveLobsterKey: () => void saveLobsterKey(),
onSaveXhsFeishuConfig: () => void saveXhsFeishuConfig(),
onResetXhsFeishuConfig: resetXhsFeishuSettingsDrafts,
onSaveCopywritingConfig: () => void saveCopywritingConfig(),
onResetCopywritingConfig: resetCopywritingSettingsDrafts,
onSaveImageConfig: () => void saveImageConfig(),
onResetImageConfig: resetImageSettingsDrafts,
onSaveVideoConfig: () => void saveVideoConfig(),
onResetVideoConfig: resetVideoSettingsDrafts,
onSaveDigitalHumanConfig: () => void saveDigitalHumanConfig(),
onResetDigitalHumanConfig: resetDigitalHumanSettingsDrafts,
onSaveDouyinRuntimeConfig: () => void saveDouyinRuntimeConfig(),
onResetDouyinRuntimeConfig: resetDouyinRuntimeSettingsDrafts,
pickWorkspaceDirectory,
exportDiagnostics
} satisfies ComponentProps<typeof SettingsPanels>;
......
import { useState } from "react"
import type { AppConfig } from "@qjclaw/shared-types"
import { StatusChip } from "../../components/ui/StatusChip"
import { Tabs, type TabItem } from "../../components/ui/Tabs"
type SetDraft = (value: string) => void
type SettingsActionHandler = () => void | Promise<void>
interface SettingsDrafts {
lobsterKey: string
......@@ -55,66 +57,163 @@ interface SettingsPanelsProps {
config: AppConfig | null
workspaceApiKeyConfigured: boolean
displayedWorkspacePath: string
hasPendingWorkspacePathChange: boolean
saving: boolean
hasPendingLobsterKey: boolean
hasPendingWorkspacePathChange: boolean
hasPendingXhsFeishuConfig: boolean
hasPendingModelKeys: boolean
saving: boolean
hasPendingCopywritingConfig: boolean
hasPendingImageConfig: boolean
hasPendingVideoConfig: boolean
hasPendingDigitalHumanConfig: boolean
hasPendingDouyinRuntimeConfig: boolean
labels: {
saving: string
save: string
export: string
}
drafts: SettingsDrafts
setters: SettingsDraftSetters
saveConfig: (options?: { lobsterKey?: string }) => void | Promise<void>
saveWorkspaceDirectory: () => void | Promise<void>
restoreWorkspaceDirectory: () => void
onSaveLobsterKey: SettingsActionHandler
onSaveXhsFeishuConfig: SettingsActionHandler
onResetXhsFeishuConfig: SettingsActionHandler
onSaveCopywritingConfig: SettingsActionHandler
onResetCopywritingConfig: SettingsActionHandler
onSaveImageConfig: SettingsActionHandler
onResetImageConfig: SettingsActionHandler
onSaveVideoConfig: SettingsActionHandler
onResetVideoConfig: SettingsActionHandler
onSaveDigitalHumanConfig: SettingsActionHandler
onResetDigitalHumanConfig: SettingsActionHandler
onSaveDouyinRuntimeConfig: SettingsActionHandler
onResetDouyinRuntimeConfig: SettingsActionHandler
onConfigSourceChange?: (source: SettingsConfigSource) => void | Promise<void>
loadCloudConfig?: () => void | Promise<void>
pickWorkspaceDirectory: () => void | Promise<void>
exportDiagnostics: () => void | Promise<void>
}
interface SectionActions {
hasPending: boolean
onSave: SettingsActionHandler
onReset: SettingsActionHandler
className?: string
}
type SettingsTabId = "basic" | "xhsFeishu" | "copywriting" | "image" | "video" | "digitalHuman" | "douyinRuntime"
type SettingsConfigSource = "cloud" | "local"
const settingsTabs: TabItem[] = [
{ id: "basic", label: "基础配置" },
{ id: "xhsFeishu", label: "小红书飞书" },
{ id: "copywriting", label: "文案模型" },
{ id: "image", label: "生图模型" },
{ id: "video", label: "视频模型" },
{ id: "digitalHuman", label: "数字人配置" },
{ id: "douyinRuntime", label: "抖音运行时" }
]
export function SettingsPanels({
config,
workspaceApiKeyConfigured,
displayedWorkspacePath,
hasPendingWorkspacePathChange,
saving,
hasPendingLobsterKey,
hasPendingWorkspacePathChange,
hasPendingXhsFeishuConfig,
hasPendingModelKeys,
saving,
hasPendingCopywritingConfig,
hasPendingImageConfig,
hasPendingVideoConfig,
hasPendingDigitalHumanConfig,
hasPendingDouyinRuntimeConfig,
labels,
drafts,
setters,
saveConfig,
saveWorkspaceDirectory,
restoreWorkspaceDirectory,
onSaveLobsterKey,
onSaveXhsFeishuConfig,
onResetXhsFeishuConfig,
onSaveCopywritingConfig,
onResetCopywritingConfig,
onSaveImageConfig,
onResetImageConfig,
onSaveVideoConfig,
onResetVideoConfig,
onSaveDigitalHumanConfig,
onResetDigitalHumanConfig,
onSaveDouyinRuntimeConfig,
onResetDouyinRuntimeConfig,
onConfigSourceChange,
loadCloudConfig,
pickWorkspaceDirectory,
exportDiagnostics
}: SettingsPanelsProps) {
const xhsFeishuConfigured = Boolean(
config?.xhsFeishuConfig.appIdConfigured
&& config?.xhsFeishuConfig.appSecretConfigured
&& config?.xhsFeishuConfig.appTokenConfigured
&& config?.xhsFeishuConfig.tableIdConfigured
)
const digitalHumanConfigured = Boolean(
config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured
&& config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured
&& config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured
&& config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured
const [activeTab, setActiveTab] = useState<SettingsTabId>("basic")
const [configSource, setConfigSource] = useState<SettingsConfigSource>("local")
const handleConfigSourceChange = (source: SettingsConfigSource) => {
setConfigSource(source)
void onConfigSourceChange?.(source)
if (source === "cloud") {
void loadCloudConfig?.()
}
}
const renderActions = ({ hasPending, onReset, onSave, className }: SectionActions) => (
<div className={["settings-actions-row", className].filter(Boolean).join(" ")}>
<button
type="button"
className="settings-action-button settings-action-button-secondary"
disabled={saving || !hasPending}
onClick={() => void onReset()}
>
重置
</button>
<button
type="button"
className="settings-action-button settings-action-button-primary"
disabled={saving || !hasPending}
onClick={() => void onSave()}
>
{saving ? "保存中" : "保存"}
</button>
</div>
)
return (
<>
<div className="settings-tabs-layout">
<div className="settings-tabs-toolbar">
<div className="settings-tabs-row">
<Tabs
items={settingsTabs}
value={activeTab}
onValueChange={(value) => setActiveTab(value as SettingsTabId)}
ariaLabel="设置分组"
className="settings-tabs"
/>
</div>
<div className="settings-config-source-toggle" role="group" aria-label="配置来源">
<button
type="button"
className={"settings-config-source-option settings-config-source-option-cloud" + (configSource === "cloud" ? " active" : "")}
aria-pressed={configSource === "cloud"}
onClick={() => handleConfigSourceChange("cloud")}
>
云端配置
</button>
<button
type="button"
className={"settings-config-source-option settings-config-source-option-local" + (configSource === "local" ? " active" : "")}
aria-pressed={configSource === "local"}
onClick={() => handleConfigSourceChange("local")}
>
本地配置
</button>
</div>
</div>
{activeTab === "basic" ? (
<section className="panel settings-panel settings-panel-modern settings-panel-basic-config">
<div className="settings-section-card settings-section-card-compact settings-basic-config-card">
<div className="settings-section-headline settings-section-headline-basic">
<span className="settings-section-kicker">基础配置</span>
</div>
<div className="settings-basic-config-form">
<div className="settings-basic-config-row">
<label className="settings-input-label">
<div className="settings-basic-config-row settings-basic-config-row-key">
<label className="settings-input-label settings-basic-config-field">
<span className="settings-input-label-text">龙虾密钥</span>
<input
className="settings-truncated-input"
......@@ -125,42 +224,60 @@ export function SettingsPanels({
onChange={(event) => setters.setLobsterKey(event.target.value)}
/>
</label>
<button className="settings-primary-button settings-inline-save-button" disabled={saving || !hasPendingLobsterKey} onClick={() => void saveConfig({ lobsterKey: drafts.lobsterKey })}>{saving ? labels.saving : "保存"}</button>
<div className="settings-actions-row settings-actions-row-inline-save">
<button
type="button"
className="settings-action-button settings-action-button-primary settings-inline-save-button"
disabled={saving || !hasPendingLobsterKey}
onClick={() => void onSaveLobsterKey()}
>
{saving ? "保存中" : "保存"}
</button>
</div>
</div>
<div className="settings-basic-config-row settings-basic-config-row-directory">
<div className="settings-input-label settings-directory-label">
<div className="settings-input-label settings-directory-label settings-basic-config-field">
<span className="settings-input-label-text">工作目录</span>
<div className="workspace-directory-card settings-basic-directory-card">
<div className="workspace-directory-panel settings-basic-directory-panel">
<div className="workspace-directory-panel settings-basic-directory-panel settings-readonly-field">
<strong className="workspace-directory-path" title={displayedWorkspacePath}>{displayedWorkspacePath}</strong>
</div>
{hasPendingWorkspacePathChange ? (
<div className="workspace-directory-draft-row">
<span className="workspace-directory-draft-badge">待保存</span>
<div className="workspace-directory-inline-actions">
<button disabled={saving} onClick={() => void saveWorkspaceDirectory()}>{saving ? labels.saving : labels.save}</button>
<button className="secondary workspace-directory-inline-button" disabled={saving} onClick={restoreWorkspaceDirectory}>恢复当前</button>
</div>
</div>
) : null}
<div className="workspace-directory-hint">当前目录已修改。</div>
) : (
<div className="workspace-directory-hint">导出诊断不会参与保存状态。</div>
)}
</div>
</div>
<div className="button-row settings-actions workspace-directory-actions settings-basic-directory-actions">
<button className="settings-primary-button" disabled={saving || !config} onClick={() => void pickWorkspaceDirectory()}>更改目录</button>
<button className="secondary" disabled={saving} onClick={() => void exportDiagnostics()}>{labels.export}</button>
<div className="button-row settings-actions-row workspace-directory-actions settings-basic-directory-actions">
<button
type="button"
className="settings-action-button settings-action-button-secondary"
disabled={saving || !config}
onClick={() => void pickWorkspaceDirectory()}
>
更改目录
</button>
<button
type="button"
className="settings-action-button settings-action-button-secondary"
disabled={saving}
onClick={() => void exportDiagnostics()}
>
{labels.export}
</button>
</div>
</div>
</div>
</div>
</section>
) : null}
{activeTab === "xhsFeishu" ? (
<section className="panel settings-panel settings-panel-secondary settings-panel-xhs-feishu">
<div className="settings-section-card settings-section-card-compact">
<div className="settings-section-headline">
<div>
<span className="settings-section-kicker">小红书飞书配置</span>
</div>
<StatusChip tone={xhsFeishuConfigured ? "positive" : "warning"}>{xhsFeishuConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<div className="settings-section-card settings-section-card-compact-body-actions">
<div className="settings-xhs-feishu-form">
<div className="settings-field-grid settings-field-grid-xhs-feishu">
<label className="settings-input-label">
<span className="settings-input-label-text">FEISHU_APP_ID</span>
......@@ -179,24 +296,26 @@ export function SettingsPanels({
<input type="password" value={drafts.xhsFeishuTableId} placeholder={config?.xhsFeishuConfig.tableIdConfigured ? "留空则保持当前已保存密钥" : "请输入 Table ID"} onChange={(event) => setters.setXhsFeishuTableId(event.target.value)} />
</label>
</div>
<div className="button-row settings-actions">
<button className="settings-primary-button" disabled={saving || !hasPendingXhsFeishuConfig} onClick={() => void saveConfig()}>{saving ? labels.saving : "保存"}</button>
{renderActions({
hasPending: hasPendingXhsFeishuConfig,
onReset: () => void onResetXhsFeishuConfig(),
onSave: () => void onSaveXhsFeishuConfig(),
className: "settings-xhs-feishu-actions"
})}
</div>
</div>
</section>
) : null}
{activeTab === "copywriting" ? (
<section className="panel settings-panel settings-panel-models">
<div className="settings-section-card settings-section-card-models">
<div className="settings-section-headline settings-section-headline-minimal">
<span className="settings-section-kicker">专家模型配置</span>
</div>
<div className="model-config-grid model-config-grid-four">
<div className="model-config-grid model-config-grid-single">
<article className="model-config-card model-config-card-copywriting">
<div className="model-config-card-head">
<div>
<strong>文案模型</strong>
<p>用于标题、脚本、口播稿与内容润色</p>
</div>
<StatusChip tone={config?.expertModelConfig.copywriting.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.copywriting.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<div className="model-config-card-body">
<div className="settings-field-grid single">
......@@ -205,14 +324,26 @@ export function SettingsPanels({
</label>
</div>
</div>
{renderActions({
hasPending: hasPendingCopywritingConfig,
onReset: () => void onResetCopywritingConfig(),
onSave: () => void onSaveCopywritingConfig()
})}
</article>
</div>
</div>
</section>
) : null}
{activeTab === "image" ? (
<section className="panel settings-panel settings-panel-models">
<div className="settings-section-card settings-section-card-models">
<div className="model-config-grid model-config-grid-single">
<article className="model-config-card model-config-card-image">
<div className="model-config-card-head">
<div>
<strong>生图模型</strong>
<p>用于封面草图、画面创意和视觉素材生成</p>
</div>
<StatusChip tone={config?.expertModelConfig.image.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.image.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<div className="model-config-card-body">
<div className="settings-field-grid single">
......@@ -221,14 +352,26 @@ export function SettingsPanels({
</label>
</div>
</div>
{renderActions({
hasPending: hasPendingImageConfig,
onReset: () => void onResetImageConfig(),
onSave: () => void onSaveImageConfig()
})}
</article>
</div>
</div>
</section>
) : null}
{activeTab === "video" ? (
<section className="panel settings-panel settings-panel-models">
<div className="settings-section-card settings-section-card-models">
<div className="model-config-grid model-config-grid-single">
<article className="model-config-card model-config-card-video">
<div className="model-config-card-head">
<div>
<strong>普通视频模型</strong>
<p>用于纯画面视频生成</p>
</div>
<StatusChip tone={config?.expertModelConfig.video.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.video.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<div className="model-config-card-body">
<div className="settings-field-grid single">
......@@ -237,14 +380,26 @@ export function SettingsPanels({
</label>
</div>
</div>
{renderActions({
hasPending: hasPendingVideoConfig,
onReset: () => void onResetVideoConfig(),
onSave: () => void onSaveVideoConfig()
})}
</article>
</div>
</div>
</section>
) : null}
{activeTab === "digitalHuman" ? (
<section className="panel settings-panel settings-panel-models">
<div className="settings-section-card settings-section-card-models">
<div className="model-config-grid model-config-grid-single">
<article className="model-config-card model-config-card-digital-human">
<div className="model-config-card-head">
<div>
<strong>数字人配置</strong>
<p>用于数字人口播视频,火山与七牛的四个 Key</p>
</div>
<StatusChip tone={digitalHumanConfigured ? "positive" : "warning"}>{digitalHumanConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<div className="model-config-card-body model-config-card-body-digital-human">
<div className="settings-field-grid settings-field-grid-digital-human">
......@@ -266,14 +421,26 @@ export function SettingsPanels({
</label>
</div>
</div>
{renderActions({
hasPending: hasPendingDigitalHumanConfig,
onReset: () => void onResetDigitalHumanConfig(),
onSave: () => void onSaveDigitalHumanConfig()
})}
</article>
<article className="model-config-card model-config-card-video-analyzer">
</div>
</div>
</section>
) : null}
{activeTab === "douyinRuntime" ? (
<section className="panel settings-panel settings-panel-models">
<div className="settings-section-card settings-section-card-models">
<div className="model-config-grid model-config-grid-four model-config-grid-runtime">
<article className="model-config-card model-config-card-video-analyzer" aria-label="Video Analyzer">
<div className="model-config-card-head">
<div>
<strong>Video Analyzer</strong>
<p>用于抖音样本分析阶段。</p>
</div>
<StatusChip tone={config?.douyinRuntimeConfig.videoAnalyzer.apiKeyConfigured ? "positive" : "warning"}>{config?.douyinRuntimeConfig.videoAnalyzer.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<div className="model-config-card-body">
<div className="settings-field-grid">
......@@ -292,13 +459,11 @@ export function SettingsPanels({
</div>
</div>
</article>
<article className="model-config-card model-config-card-replication-brief">
<article className="model-config-card model-config-card-replication-brief" aria-label="Replication Brief">
<div className="model-config-card-head">
<div>
<strong>Replication Brief</strong>
<p>用于抖音 brief 生成阶段。</p>
</div>
<StatusChip tone={config?.douyinRuntimeConfig.replicationBrief.apiKeyConfigured ? "positive" : "warning"}>{config?.douyinRuntimeConfig.replicationBrief.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<div className="model-config-card-body">
<div className="settings-field-grid">
......@@ -317,13 +482,11 @@ export function SettingsPanels({
</div>
</div>
</article>
<article className="model-config-card model-config-card-vectcut">
<article className="model-config-card model-config-card-vectcut" aria-label="VectCut">
<div className="model-config-card-head">
<div>
<strong>VectCut</strong>
<p>用于抖音后处理剪辑阶段。</p>
</div>
<StatusChip tone={config?.douyinRuntimeConfig.vectcut.apiKeyConfigured ? "positive" : "warning"}>{config?.douyinRuntimeConfig.vectcut.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<div className="model-config-card-body">
<div className="settings-field-grid">
......@@ -342,12 +505,18 @@ export function SettingsPanels({
</div>
</div>
</article>
<div className="settings-runtime-actions-panel">
{renderActions({
hasPending: hasPendingDouyinRuntimeConfig,
onReset: () => void onResetDouyinRuntimeConfig(),
onSave: () => void onSaveDouyinRuntimeConfig(),
className: "settings-runtime-actions"
})}
</div>
<div className="button-row settings-actions">
<button className="settings-primary-button" disabled={saving || !hasPendingModelKeys} onClick={() => void saveConfig()}>{saving ? labels.saving : "保存模型配置"}</button>
</div>
</div>
</section>
</>
) : null}
</div>
)
}
......@@ -5,7 +5,10 @@ interface SettingsViewProps {
children: ReactNode
}
export function SettingsView({ statusHint, children }: SettingsViewProps) {
export function SettingsView({
statusHint,
children
}: SettingsViewProps) {
return (
<div className="page-stack settings-page-stack settings-page-shell">
{statusHint}
......
import type { AppConfig } from "@qjclaw/shared-types"
export interface PendingSettingsFlags {
hasPendingBasicConfig: boolean
hasPendingXhsFeishuConfig: boolean
hasPendingCopywritingConfig: boolean
hasPendingImageConfig: boolean
hasPendingVideoConfig: boolean
hasPendingDigitalHumanConfig: boolean
hasPendingDouyinRuntimeConfig: boolean
}
export interface BasicResetSettingsDrafts {
lobsterKey: string
workspacePath: string
}
export interface DigitalHumanResetSettingsDrafts {
digitalHumanVolcAccessKey: string
digitalHumanVolcSecretKey: string
digitalHumanQiniuAccessKey: string
digitalHumanQiniuSecretKey: string
}
export interface DouyinRuntimeResetSettingsDrafts {
videoAnalyzerBaseUrl: string
videoAnalyzerModelId: string
videoAnalyzerApiKey: string
replicationBriefBaseUrl: string
replicationBriefModelId: string
replicationBriefApiKey: string
vectcutBaseUrl: string
vectcutFileBaseUrl: string
vectcutApiKey: string
}
export interface XhsFeishuResetSettingsDrafts {
xhsFeishuAppId: string
xhsFeishuAppSecret: string
xhsFeishuAppToken: string
xhsFeishuTableId: string
}
export function getHasPendingSettingsChange(flags: PendingSettingsFlags) {
return flags.hasPendingBasicConfig
|| flags.hasPendingXhsFeishuConfig
|| flags.hasPendingCopywritingConfig
|| flags.hasPendingImageConfig
|| flags.hasPendingVideoConfig
|| flags.hasPendingDigitalHumanConfig
|| flags.hasPendingDouyinRuntimeConfig
}
export function getResetBasicSettingsDrafts(config: AppConfig): BasicResetSettingsDrafts {
return {
lobsterKey: "",
workspacePath: config.workspacePath
}
}
export function getResetCopywritingSettingsDrafts() {
return {
copywritingModelApiKey: ""
}
}
export function getResetImageSettingsDrafts() {
return {
imageModelApiKey: ""
}
}
export function getResetVideoSettingsDrafts() {
return {
videoModelApiKey: ""
}
}
export function getResetDigitalHumanSettingsDrafts(): DigitalHumanResetSettingsDrafts {
return {
digitalHumanVolcAccessKey: "",
digitalHumanVolcSecretKey: "",
digitalHumanQiniuAccessKey: "",
digitalHumanQiniuSecretKey: ""
}
}
export function getResetDouyinRuntimeSettingsDrafts(config: AppConfig): DouyinRuntimeResetSettingsDrafts {
return {
videoAnalyzerBaseUrl: config.douyinRuntimeConfig.videoAnalyzer.baseUrl,
videoAnalyzerModelId: config.douyinRuntimeConfig.videoAnalyzer.modelId ?? "",
videoAnalyzerApiKey: "",
replicationBriefBaseUrl: config.douyinRuntimeConfig.replicationBrief.baseUrl,
replicationBriefModelId: config.douyinRuntimeConfig.replicationBrief.modelId ?? "",
replicationBriefApiKey: "",
vectcutBaseUrl: config.douyinRuntimeConfig.vectcut.baseUrl,
vectcutFileBaseUrl: config.douyinRuntimeConfig.vectcut.fileBaseUrl,
vectcutApiKey: ""
}
}
export function getResetXhsFeishuSettingsDrafts(): XhsFeishuResetSettingsDrafts {
return {
xhsFeishuAppId: "",
xhsFeishuAppSecret: "",
xhsFeishuAppToken: "",
xhsFeishuTableId: ""
}
}
......@@ -77,67 +77,36 @@ export function useSaveSettings({
douyinRuntimeConfig?: SaveConfigInput["douyinRuntimeConfig"]
xhsFeishuConfig?: SaveConfigInput["xhsFeishuConfig"]
successMessage?: string
resetDrafts?: (savedConfig: AppConfig) => void
}) => {
if (!config) {
if (!config || saving) {
return
}
const trimmedLobsterKey = options?.lobsterKey?.trim() ?? drafts.lobsterKey.trim()
const resolvedWorkspacePath = options?.workspacePath?.trim() || drafts.workspacePath.trim() || config.workspacePath
const resolvedExpertModelConfig = options?.expertModelConfig ?? {
image: {
apiKey: drafts.imageModelApiKey.trim() || undefined
},
video: {
apiKey: drafts.videoModelApiKey.trim() || undefined
},
copywriting: {
apiKey: drafts.copywritingModelApiKey.trim() || undefined
},
digitalHuman: {
volcAccessKey: drafts.digitalHumanVolcAccessKey.trim() || undefined,
volcSecretKey: drafts.digitalHumanVolcSecretKey.trim() || undefined,
qiniuAccessKey: drafts.digitalHumanQiniuAccessKey.trim() || undefined,
qiniuSecretKey: drafts.digitalHumanQiniuSecretKey.trim() || undefined
}
}
const resolvedDouyinRuntimeConfig = options?.douyinRuntimeConfig ?? {
videoAnalyzer: {
baseUrl: drafts.videoAnalyzerBaseUrl.trim() || undefined,
modelId: drafts.videoAnalyzerModelId.trim() || undefined,
apiKey: drafts.videoAnalyzerApiKey.trim() || undefined
},
replicationBrief: {
baseUrl: drafts.replicationBriefBaseUrl.trim() || undefined,
modelId: drafts.replicationBriefModelId.trim() || undefined,
apiKey: drafts.replicationBriefApiKey.trim() || undefined
},
vectcut: {
baseUrl: drafts.vectcutBaseUrl.trim() || undefined,
fileBaseUrl: drafts.vectcutFileBaseUrl.trim() || undefined,
apiKey: drafts.vectcutApiKey.trim() || undefined
}
}
const resolvedXhsFeishuConfig = options?.xhsFeishuConfig ?? {
appId: drafts.xhsFeishuAppId.trim() || undefined,
appSecret: drafts.xhsFeishuAppSecret.trim() || undefined,
appToken: drafts.xhsFeishuAppToken.trim() || undefined,
tableId: drafts.xhsFeishuTableId.trim() || undefined
}
const input: SaveConfigInput = {
setupMode: config.setupMode,
provider: config.provider,
baseUrl: config.baseUrl,
defaultModel: config.defaultModel,
workspacePath: resolvedWorkspacePath,
workspacePath: options?.workspacePath?.trim() || config.workspacePath,
gatewayUrl: config.gatewayUrl,
cloudApiBaseUrl: config.cloudApiBaseUrl,
runtimeCloudApiBaseUrl: config.runtimeCloudApiBaseUrl.trim(),
runtimeMode: "bundled-runtime",
expertModelConfig: resolvedExpertModelConfig,
douyinRuntimeConfig: resolvedDouyinRuntimeConfig,
xhsFeishuConfig: resolvedXhsFeishuConfig,
...(trimmedLobsterKey ? { apiKey: trimmedLobsterKey } : {})
runtimeMode: "bundled-runtime"
}
const trimmedLobsterKey = options?.lobsterKey?.trim()
if (typeof options?.lobsterKey === "string" && trimmedLobsterKey) {
input.apiKey = trimmedLobsterKey
}
if (options?.expertModelConfig) {
input.expertModelConfig = options.expertModelConfig
}
if (options?.douyinRuntimeConfig) {
input.douyinRuntimeConfig = options.douyinRuntimeConfig
}
if (options?.xhsFeishuConfig) {
input.xhsFeishuConfig = options.xhsFeishuConfig
}
setters.setSaving(true)
......@@ -147,22 +116,7 @@ export function useSaveSettings({
try {
const savedConfig = await desktopApi.config.save(input)
setters.setConfig(savedConfig)
setters.setWorkspacePathDraft(savedConfig.workspacePath)
setters.setLobsterKeyDraft("")
setters.setImageModelApiKeyDraft("")
setters.setVideoModelApiKeyDraft("")
setters.setCopywritingModelApiKeyDraft("")
setters.setDigitalHumanVolcAccessKeyDraft("")
setters.setDigitalHumanVolcSecretKeyDraft("")
setters.setDigitalHumanQiniuAccessKeyDraft("")
setters.setDigitalHumanQiniuSecretKeyDraft("")
setters.setVideoAnalyzerApiKeyDraft("")
setters.setReplicationBriefApiKeyDraft("")
setters.setVectcutApiKeyDraft("")
setters.setXhsFeishuAppIdDraft("")
setters.setXhsFeishuAppSecretDraft("")
setters.setXhsFeishuAppTokenDraft("")
setters.setXhsFeishuTableIdDraft("")
options?.resetDrafts?.(savedConfig)
setters.setInfoText(options?.successMessage ?? (trimmedLobsterKey ? labels.saveSuccessPending : labels.saveSuccessApplied))
void refresh(false)
} catch (error) {
......@@ -170,7 +124,16 @@ export function useSaveSettings({
} finally {
setters.setSaving(false)
}
}, [config, desktopApi, drafts, labels.saveSuccessApplied, labels.saveSuccessPending, normalizeError, refresh, setters])
}, [config, desktopApi, labels.saveSuccessApplied, labels.saveSuccessPending, normalizeError, refresh, saving, setters])
const saveLobsterKey = useCallback(async () => {
await saveConfig({
lobsterKey: drafts.lobsterKey,
resetDrafts: () => {
setters.setLobsterKeyDraft("")
}
})
}, [drafts.lobsterKey, saveConfig, setters])
const pickWorkspaceDirectory = useCallback(async () => {
if (!config || saving) {
......@@ -204,14 +167,138 @@ export function useSaveSettings({
const saveWorkspaceDirectory = useCallback(async () => {
await saveConfig({
workspacePath: drafts.workspacePath,
successMessage: labels.workspaceSaved
successMessage: labels.workspaceSaved,
resetDrafts: (savedConfig) => {
setters.setWorkspacePathDraft(savedConfig.workspacePath)
}
})
}, [drafts.workspacePath, labels.workspaceSaved, saveConfig, setters])
const saveXhsFeishuConfig = useCallback(async () => {
await saveConfig({
xhsFeishuConfig: {
appId: drafts.xhsFeishuAppId.trim() || undefined,
appSecret: drafts.xhsFeishuAppSecret.trim() || undefined,
appToken: drafts.xhsFeishuAppToken.trim() || undefined,
tableId: drafts.xhsFeishuTableId.trim() || undefined
},
resetDrafts: () => {
setters.setXhsFeishuAppIdDraft("")
setters.setXhsFeishuAppSecretDraft("")
setters.setXhsFeishuAppTokenDraft("")
setters.setXhsFeishuTableIdDraft("")
}
})
}, [drafts.workspacePath, labels.workspaceSaved, saveConfig])
}, [drafts.xhsFeishuAppId, drafts.xhsFeishuAppSecret, drafts.xhsFeishuAppToken, drafts.xhsFeishuTableId, saveConfig, setters])
const saveCopywritingConfig = useCallback(async () => {
await saveConfig({
expertModelConfig: {
copywriting: {
apiKey: drafts.copywritingModelApiKey.trim() || undefined
}
},
resetDrafts: () => {
setters.setCopywritingModelApiKeyDraft("")
}
})
}, [drafts.copywritingModelApiKey, saveConfig, setters])
const saveImageConfig = useCallback(async () => {
await saveConfig({
expertModelConfig: {
image: {
apiKey: drafts.imageModelApiKey.trim() || undefined
}
},
resetDrafts: () => {
setters.setImageModelApiKeyDraft("")
}
})
}, [drafts.imageModelApiKey, saveConfig, setters])
const saveVideoConfig = useCallback(async () => {
await saveConfig({
expertModelConfig: {
video: {
apiKey: drafts.videoModelApiKey.trim() || undefined
}
},
resetDrafts: () => {
setters.setVideoModelApiKeyDraft("")
}
})
}, [drafts.videoModelApiKey, saveConfig, setters])
const saveDigitalHumanConfig = useCallback(async () => {
await saveConfig({
expertModelConfig: {
digitalHuman: {
volcAccessKey: drafts.digitalHumanVolcAccessKey.trim() || undefined,
volcSecretKey: drafts.digitalHumanVolcSecretKey.trim() || undefined,
qiniuAccessKey: drafts.digitalHumanQiniuAccessKey.trim() || undefined,
qiniuSecretKey: drafts.digitalHumanQiniuSecretKey.trim() || undefined
}
},
resetDrafts: () => {
setters.setDigitalHumanVolcAccessKeyDraft("")
setters.setDigitalHumanVolcSecretKeyDraft("")
setters.setDigitalHumanQiniuAccessKeyDraft("")
setters.setDigitalHumanQiniuSecretKeyDraft("")
}
})
}, [drafts.digitalHumanQiniuAccessKey, drafts.digitalHumanQiniuSecretKey, drafts.digitalHumanVolcAccessKey, drafts.digitalHumanVolcSecretKey, saveConfig, setters])
const saveDouyinRuntimeConfig = useCallback(async () => {
await saveConfig({
douyinRuntimeConfig: {
videoAnalyzer: {
baseUrl: drafts.videoAnalyzerBaseUrl.trim() || undefined,
modelId: drafts.videoAnalyzerModelId.trim() || undefined,
apiKey: drafts.videoAnalyzerApiKey.trim() || undefined
},
replicationBrief: {
baseUrl: drafts.replicationBriefBaseUrl.trim() || undefined,
modelId: drafts.replicationBriefModelId.trim() || undefined,
apiKey: drafts.replicationBriefApiKey.trim() || undefined
},
vectcut: {
baseUrl: drafts.vectcutBaseUrl.trim() || undefined,
fileBaseUrl: drafts.vectcutFileBaseUrl.trim() || undefined,
apiKey: drafts.vectcutApiKey.trim() || undefined
}
},
resetDrafts: () => {
setters.setVideoAnalyzerApiKeyDraft("")
setters.setReplicationBriefApiKeyDraft("")
setters.setVectcutApiKeyDraft("")
}
})
}, [
drafts.replicationBriefApiKey,
drafts.replicationBriefBaseUrl,
drafts.replicationBriefModelId,
drafts.vectcutApiKey,
drafts.vectcutBaseUrl,
drafts.vectcutFileBaseUrl,
drafts.videoAnalyzerApiKey,
drafts.videoAnalyzerBaseUrl,
drafts.videoAnalyzerModelId,
saveConfig,
setters
])
return {
saveConfig,
saveLobsterKey,
saveWorkspaceDirectory,
restoreWorkspaceDirectory,
saveXhsFeishuConfig,
saveCopywritingConfig,
saveImageConfig,
saveVideoConfig,
saveDigitalHumanConfig,
saveDouyinRuntimeConfig,
pickWorkspaceDirectory
}
}
......@@ -8,19 +8,96 @@
overflow: hidden;
}
.settings-page-shell {
--settings-control-height: 44px;
--settings-control-radius: 16px;
--settings-card-radius: 24px;
--settings-control-padding-x: 14px;
--settings-control-border: rgba(167, 183, 224, 0.88);
--settings-control-border-strong: rgba(129, 150, 205, 0.92);
--settings-control-background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(245, 248, 255, 0.92) 100%);
--settings-focus-outline: rgba(59, 130, 246, 0.16);
--settings-focus-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14);
--settings-secondary-shadow: 0 10px 22px rgba(35, 52, 82, 0.08);
}
.settings-console-grid {
flex: 1 1 auto;
min-height: 0;
display: grid;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
}
.settings-tabs-layout {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: minmax(236px, 0.58fr) minmax(0, 1.42fr);
grid-template-areas:
"basic-config xhs-feishu"
"models models";
overflow: hidden;
}
.settings-tabs-toolbar {
flex: 0 0 auto;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.settings-tabs-row {
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.settings-tabs-row::-webkit-scrollbar {
display: none;
}
.settings-tabs {
width: max-content;
max-width: none;
}
.settings-config-source-toggle {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 2px;
min-height: var(--settings-control-height);
padding: 4px;
border-radius: 999px;
background: linear-gradient(180deg, rgba(229, 239, 235, 0.94), rgba(216, 231, 224, 0.9));
box-shadow: inset 0 0 0 1px rgba(170, 191, 182, 0.18);
}
.settings-config-source-option {
min-width: 94px;
min-height: calc(var(--settings-control-height) - 8px);
padding: 0 14px;
border: 0;
border-radius: 999px;
background: transparent;
color: #557062;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
box-shadow: none;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.settings-config-source-option.active {
background: #ffffff;
color: #176046;
box-shadow: 0 8px 18px rgba(36, 84, 64, 0.12), inset 0 0 0 1px rgba(168, 189, 179, 0.22);
}
.settings-panel-basic-config {
grid-area: basic-config;
}
......@@ -44,10 +121,6 @@
padding: 8px;
}
.settings-panel-xhs-feishu .settings-section-headline {
align-items: center;
}
.settings-panel-xhs-feishu .settings-section-kicker {
min-height: 22px;
padding-inline: 9px;
......@@ -68,6 +141,7 @@
}
.settings-panel {
flex: 1 1 auto;
min-height: 0;
padding: 8px;
border-radius: 24px;
......@@ -118,11 +192,19 @@
grid-template-rows: auto minmax(0, 1fr) auto;
}
.settings-section-card-compact-body-actions {
grid-template-rows: minmax(0, 1fr) auto;
}
.settings-section-card-models {
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
}
.settings-tabs-layout .settings-section-card-models {
grid-template-rows: minmax(0, 1fr);
}
.settings-section-headline {
display: flex;
align-items: flex-start;
......@@ -193,69 +275,57 @@
}
.settings-basic-config-card {
grid-template-rows: auto minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
gap: 8px;
}
.settings-section-headline-basic {
align-items: flex-start;
justify-content: flex-start;
}
.settings-basic-config-form {
min-height: 0;
display: grid;
align-content: center;
gap: 10px;
align-content: start;
gap: 14px;
}
.settings-basic-config-row {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 146px;
grid-template-columns: minmax(0, 560px) auto;
align-items: end;
gap: 10px;
gap: 12px;
}
.settings-basic-config-row-key {
grid-template-columns: minmax(0, 560px) auto;
}
.settings-basic-config-row-directory {
align-items: start;
align-items: end;
}
.settings-basic-config-field {
width: 100%;
}
.settings-basic-directory-card {
gap: 6px;
gap: 0;
}
.settings-basic-directory-actions {
display: grid;
grid-auto-flow: column;
grid-auto-columns: unset;
grid-template-columns: repeat(2, 70px);
align-self: end;
justify-self: start;
width: 146px;
}
.settings-basic-directory-actions button,
.settings-inline-save-button {
height: 38px;
min-height: 38px;
padding-block: 0;
padding-inline: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
white-space: nowrap;
flex-direction: column;
align-items: stretch;
}
.settings-inline-save-button {
min-width: 72px;
justify-self: start;
.settings-inline-save-button,
.settings-basic-directory-actions .settings-action-button {
width: 96px;
min-width: 96px;
}
.settings-input-label {
min-width: 0;
gap: 5px;
gap: 6px;
}
.settings-input-label-text {
......@@ -270,11 +340,16 @@
.settings-input-label input,
.settings-input-label textarea,
.settings-input-label select {
min-height: 38px;
padding: 9px 12px;
border-color: rgba(170, 187, 228, 0.82);
background: rgba(252, 253, 255, 0.92);
min-height: var(--settings-control-height);
height: var(--settings-control-height);
padding: 0 var(--settings-control-padding-x);
border: 1px solid var(--settings-control-border);
border-radius: var(--settings-control-radius);
background: var(--settings-control-background);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92);
color: #163152;
font-size: 13px;
line-height: 1.35;
}
.settings-truncated-input {
......@@ -286,10 +361,10 @@
.settings-input-label input:focus-visible,
.settings-input-label textarea:focus-visible,
.settings-input-label select:focus-visible {
outline: 2px solid rgba(109, 124, 255, 0.16);
outline: 2px solid var(--settings-focus-outline);
outline-offset: 0;
border-color: rgba(109, 124, 255, 0.42);
box-shadow: 0 0 0 4px rgba(94, 203, 255, 0.12);
border-color: var(--settings-control-border-strong);
box-shadow: var(--settings-focus-shadow);
}
.model-config-grid {
......@@ -306,12 +381,19 @@
overflow-x: hidden;
padding-right: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: minmax(188px, auto);
grid-auto-rows: minmax(214px, auto);
align-content: start;
scrollbar-width: thin;
scrollbar-color: rgba(125, 143, 255, 0.34) transparent;
}
.model-config-grid-single {
min-height: 0;
height: 100%;
grid-template-columns: minmax(0, 560px);
align-content: start;
}
.model-config-grid-four::-webkit-scrollbar {
width: 8px;
}
......@@ -333,10 +415,10 @@
.model-config-card {
display: grid;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 8px;
padding: 12px;
border-radius: 18px;
border-radius: var(--settings-card-radius);
border: 1px solid rgba(178, 194, 255, 0.62);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(247, 249, 255, 0.84));
box-shadow: 0 10px 22px rgba(109, 124, 255, 0.08);
......@@ -344,16 +426,102 @@
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
}
.settings-actions-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
margin-top: 2px;
}
.settings-actions-row-inline-save {
justify-self: start;
}
.settings-action-button {
min-width: 92px;
min-height: var(--settings-control-height);
height: var(--settings-control-height);
padding: 0 16px;
border-radius: var(--settings-control-radius);
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(179, 194, 228, 0.9);
background: rgba(255, 255, 255, 0.94);
color: #2b426d;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
box-shadow: var(--settings-secondary-shadow);
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease, color 160ms ease, transform 160ms ease;
}
.settings-action-button:hover:not(:disabled) {
border-color: rgba(150, 171, 223, 0.96);
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 12px 24px rgba(35, 52, 82, 0.1);
}
.settings-action-button:focus-visible {
outline: 2px solid var(--settings-focus-outline);
outline-offset: 0;
box-shadow: var(--settings-focus-shadow);
}
.settings-action-button:disabled {
cursor: not-allowed;
opacity: 0.56;
box-shadow: none;
}
.settings-action-button-primary {
border-color: transparent;
background: linear-gradient(135deg, #3b82f6, #1e40af);
color: #ffffff;
box-shadow: 0 12px 24px rgba(37, 99, 235, 0.24);
}
.settings-action-button-primary:hover:not(:disabled) {
background: linear-gradient(135deg, #4c8dff, #2148bd);
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.28);
}
.settings-action-button-primary:focus-visible {
outline-color: rgba(37, 99, 235, 0.22);
}
.settings-action-button-secondary {
background: rgba(255, 255, 255, 0.94);
color: #2b426d;
}
.model-config-card:hover {
transform: translateY(-1px);
border-color: rgba(109, 124, 255, 0.36);
box-shadow: 0 14px 26px rgba(109, 124, 255, 0.12);
}
.settings-panel-models .model-config-grid-single .model-config-card {
padding: 0;
border: 0 !important;
border-radius: 0;
background: transparent !important;
box-shadow: none !important;
}
.settings-panel-models .model-config-grid-single .model-config-card:hover {
transform: none;
border-color: transparent !important;
box-shadow: none !important;
}
.model-config-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
justify-content: flex-start;
gap: 8px;
}
......@@ -405,95 +573,30 @@
}
.settings-field-grid-xhs-feishu .settings-input-label {
gap: 3px;
gap: 6px;
}
.settings-field-grid-xhs-feishu .settings-input-label input {
min-height: 34px;
padding: 7px 10px;
}
.settings-panel-xhs-feishu .settings-actions {
margin-top: 0;
align-items: center;
justify-content: flex-end;
}
.settings-panel-xhs-feishu .settings-primary-button {
min-width: 72px;
min-height: var(--settings-control-height);
height: var(--settings-control-height);
padding: 0 var(--settings-control-padding-x);
}
.settings-panel .status-chip {
min-height: 24px;
padding: 0 9px;
background: rgba(248, 250, 255, 0.88);
color: #536587;
box-shadow: inset 0 0 0 1px rgba(181, 195, 234, 0.88);
}
.settings-panel .status-chip.positive {
background: rgba(16, 185, 129, 0.14);
color: #0f7f59;
box-shadow: none;
}
.settings-panel .status-chip.warning {
background: rgba(245, 158, 11, 0.16);
color: #b46f0a;
box-shadow: none;
}
.settings-actions {
.settings-xhs-feishu-form {
min-height: 0;
display: grid;
align-content: start;
gap: 8px;
flex-wrap: wrap;
}
.settings-actions button {
min-height: 38px;
}
.settings-page-shell .settings-primary-button {
min-width: 100px;
min-height: 38px;
height: 38px;
padding: 0 14px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
white-space: nowrap;
background: linear-gradient(135deg, #3b82f6, #1e40af);
color: #ffffff;
box-shadow: 0 12px 24px rgba(37, 99, 235, 0.24);
}
.settings-page-shell .settings-primary-button:hover:not(:disabled) {
background: linear-gradient(135deg, #4c8dff, #2148bd);
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.28);
}
.settings-page-shell .settings-primary-button:focus-visible {
outline-color: rgba(37, 99, 235, 0.22);
}
.settings-page-shell .settings-inline-save-button,
.settings-page-shell .settings-panel-xhs-feishu .settings-primary-button {
min-width: 72px;
padding-inline: 12px;
.settings-xhs-feishu-actions {
justify-content: flex-end;
margin-top: 0;
}
.settings-page-shell .settings-basic-directory-actions button {
.settings-page-shell .settings-basic-directory-actions .settings-action-button {
width: 100%;
min-width: 0;
padding-inline: 6px;
}
.settings-page-shell .settings-actions button:not(.settings-primary-button):not(.secondary),
.settings-page-shell .workspace-directory-inline-actions button:not(.settings-primary-button):not(.secondary) {
background: rgba(255, 255, 255, 0.94);
color: #2b426d;
box-shadow: inset 0 0 0 1px rgba(179, 194, 228, 0.9);
min-width: 96px;
}
.settings-panel .workspace-directory-card {
......@@ -504,10 +607,10 @@
.settings-panel .workspace-directory-panel {
min-height: 0;
height: 100%;
padding: 12px 14px;
border-radius: 18px;
border-color: rgba(173, 192, 245, 0.72);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(245, 248, 255, 0.88) 100%);
padding: 0 var(--settings-control-padding-x);
border-radius: var(--settings-control-radius);
border: 1px solid var(--settings-control-border);
background: var(--settings-control-background);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 8px 18px rgba(109, 124, 255, 0.06);
}
......@@ -527,16 +630,22 @@
.settings-panel-basic-config .settings-input-label input,
.settings-panel-basic-config .workspace-directory-panel {
width: 100%;
min-height: 38px;
height: auto;
padding: 9px 12px;
border-radius: 18px;
border: 1px solid rgba(173, 192, 245, 0.72);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(245, 248, 255, 0.88) 100%);
min-height: var(--settings-control-height);
height: var(--settings-control-height);
padding: 0 var(--settings-control-padding-x);
border-radius: var(--settings-control-radius);
border: 1px solid var(--settings-control-border);
background: var(--settings-control-background);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 8px 18px rgba(109, 124, 255, 0.06);
}
.settings-panel-basic-config .workspace-directory-panel {
display: flex;
align-items: center;
}
.settings-readonly-field {
display: flex;
align-items: center;
}
......@@ -559,8 +668,8 @@
.workspace-directory-path {
color: #173056;
font-size: 12px;
line-height: 1.55;
word-break: break-word;
line-height: 1.45;
word-break: normal;
}
.workspace-directory-hint {
......@@ -575,18 +684,23 @@
gap: 6px;
}
@media (max-width: 1180px) {
.settings-console-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: minmax(164px, auto) minmax(0, 1fr);
grid-template-areas:
"basic-config xhs-feishu"
"models models";
}
.model-config-grid-runtime .settings-runtime-actions-panel {
min-height: 0;
height: 100%;
display: flex;
align-items: flex-end;
justify-content: flex-end;
}
.model-config-grid-runtime .settings-runtime-actions {
width: 100%;
justify-content: flex-end;
}
@media (max-width: 1180px) {
.model-config-grid-four {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: minmax(188px, auto);
grid-auto-rows: minmax(214px, auto);
}
}
......@@ -615,12 +729,11 @@
.settings-console-grid {
flex: 0 0 auto;
height: auto;
grid-template-columns: 1fr;
grid-template-rows: auto;
grid-template-areas:
"basic-config"
"xhs-feishu"
"models";
overflow: visible;
}
.settings-tabs-layout {
height: auto;
overflow: visible;
}
......@@ -639,15 +752,27 @@
grid-template-columns: minmax(0, 1fr);
}
.settings-basic-config-form {
padding-top: 0;
}
.settings-basic-config-field {
width: 100%;
}
.settings-inline-save-button,
.settings-actions-row-inline-save,
.settings-basic-directory-actions {
justify-self: stretch;
}
.settings-basic-directory-actions {
grid-auto-flow: row;
grid-auto-columns: unset;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
}
.model-config-grid-runtime .settings-runtime-actions-panel {
height: auto;
align-items: stretch;
}
.settings-field-grid-digital-human {
......@@ -656,6 +781,19 @@
}
@media (max-width: 720px) {
.settings-tabs-toolbar {
align-items: stretch;
gap: 8px;
}
.settings-config-source-toggle {
align-self: flex-start;
}
.settings-config-source-option {
padding-inline: 9px;
}
.plugin-page-topbar,
.settings-page-topbar {
padding: 0;
......@@ -683,16 +821,12 @@
flex-direction: column;
}
.settings-panel .status-chip {
align-self: flex-start;
}
.settings-actions button,
.workspace-directory-actions button {
.settings-actions-row .settings-action-button,
.workspace-directory-actions .settings-action-button {
width: 100%;
}
.settings-basic-directory-actions {
grid-template-columns: 1fr;
.settings-basic-directory-actions .settings-action-button {
min-width: 0;
}
}
import test from "node:test"
import assert from "node:assert/strict"
import type { AppConfig } from "@qjclaw/shared-types"
import {
getHasPendingSettingsChange,
getResetBasicSettingsDrafts,
getResetCopywritingSettingsDrafts,
getResetDigitalHumanSettingsDrafts,
getResetDouyinRuntimeSettingsDrafts,
getResetImageSettingsDrafts,
getResetVideoSettingsDrafts,
getResetXhsFeishuSettingsDrafts
} from "../src/features/settings/settingsDrafts.ts"
const config = {
setupMode: "employee-key",
provider: "openai",
baseUrl: "https://example.test/v1",
apiKeyConfigured: true,
gatewayTokenConfigured: false,
authTokenConfigured: false,
defaultModel: "qwen-max",
workspacePath: "/workspace/current",
gatewayUrl: "ws://127.0.0.1:8765",
cloudApiBaseUrl: "https://cloud.example.test",
runtimeCloudApiBaseUrl: "https://runtime.example.test",
runtimeMode: "bundled-runtime",
expertModelConfig: {
image: { baseUrl: "https://image.example.test", modelId: "image-model", apiKeyConfigured: true },
video: { baseUrl: "https://video.example.test", modelId: "video-model", apiKeyConfigured: true },
copywriting: { baseUrl: "https://copy.example.test", modelId: "copy-model", apiKeyConfigured: true },
digitalHuman: {
volcRegion: "cn-north-1",
volcService: "cv",
volcHost: "visual.volcengineapi.com",
volcScheme: "https",
ttsVoice: "zh-CN-YunxiNeural",
qiniuBucket: "bucket",
qiniuDomain: "https://cdn.example.test",
qiniuKeyPrefix: "omnihuman",
volcAccessKeyConfigured: true,
volcSecretKeyConfigured: true,
qiniuAccessKeyConfigured: true,
qiniuSecretKeyConfigured: true
}
},
douyinRuntimeConfig: {
videoAnalyzer: { baseUrl: "https://video-analyzer.example.test", modelId: "va-model", apiKeyConfigured: true },
replicationBrief: { baseUrl: "https://brief.example.test", modelId: "brief-model", apiKeyConfigured: true },
vectcut: { baseUrl: "https://vectcut.example.test", fileBaseUrl: "https://files.example.test", apiKeyConfigured: true }
},
xhsFeishuConfig: {
appIdConfigured: true,
appSecretConfigured: true,
appTokenConfigured: true,
tableIdConfigured: true
}
} satisfies AppConfig
test("detects any pending settings change", () => {
assert.equal(getHasPendingSettingsChange({
hasPendingBasicConfig: false,
hasPendingXhsFeishuConfig: false,
hasPendingCopywritingConfig: false,
hasPendingImageConfig: false,
hasPendingVideoConfig: false,
hasPendingDigitalHumanConfig: false,
hasPendingDouyinRuntimeConfig: false
}), false)
assert.equal(getHasPendingSettingsChange({
hasPendingBasicConfig: false,
hasPendingXhsFeishuConfig: false,
hasPendingCopywritingConfig: false,
hasPendingImageConfig: false,
hasPendingVideoConfig: false,
hasPendingDigitalHumanConfig: false,
hasPendingDouyinRuntimeConfig: true
}), true)
})
test("basic reset only affects lobster key and workspace path", () => {
assert.deepEqual(getResetBasicSettingsDrafts(config), {
lobsterKey: "",
workspacePath: "/workspace/current"
})
})
test("single model resets only clear the current module key", () => {
assert.deepEqual(getResetCopywritingSettingsDrafts(), {
copywritingModelApiKey: "",
})
assert.deepEqual(getResetImageSettingsDrafts(), {
imageModelApiKey: "",
})
assert.deepEqual(getResetVideoSettingsDrafts(), {
videoModelApiKey: "",
})
})
test("xhs feishu reset only clears its own drafts", () => {
assert.deepEqual(getResetXhsFeishuSettingsDrafts(), {
xhsFeishuAppId: "",
xhsFeishuAppSecret: "",
xhsFeishuAppToken: "",
xhsFeishuTableId: ""
})
})
test("digital human reset only clears its own secrets", () => {
assert.deepEqual(getResetDigitalHumanSettingsDrafts(), {
digitalHumanVolcAccessKey: "",
digitalHumanVolcSecretKey: "",
digitalHumanQiniuAccessKey: "",
digitalHumanQiniuSecretKey: ""
})
})
test("douyin runtime reset restores saved base urls and model ids while clearing runtime secrets", () => {
assert.deepEqual(getResetDouyinRuntimeSettingsDrafts(config), {
videoAnalyzerBaseUrl: "https://video-analyzer.example.test",
videoAnalyzerModelId: "va-model",
videoAnalyzerApiKey: "",
replicationBriefBaseUrl: "https://brief.example.test",
replicationBriefModelId: "brief-model",
replicationBriefApiKey: "",
vectcutBaseUrl: "https://vectcut.example.test",
vectcutFileBaseUrl: "https://files.example.test",
vectcutApiKey: ""
})
})
import test from "node:test"
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
const settingsPanelsSource = readFileSync(new URL("../src/features/settings/SettingsPanels.tsx", import.meta.url), "utf8")
const settingsStylesSource = readFileSync(new URL("../src/styles/settings.css", import.meta.url), "utf8")
test("settings panels remove per-module status chips", () => {
assert.doesNotMatch(settingsPanelsSource, /StatusChip/)
assert.doesNotMatch(settingsPanelsSource, /已配置/)
assert.doesNotMatch(settingsPanelsSource, /未配置/)
})
test("settings page buttons use the unified action button classes", () => {
assert.match(settingsPanelsSource, /settings-action-button settings-action-button-secondary/)
assert.match(settingsPanelsSource, /settings-action-button settings-action-button-primary/)
assert.doesNotMatch(settingsPanelsSource, /settings-card-action-button/)
assert.doesNotMatch(settingsPanelsSource, /settings-secondary-button/)
assert.doesNotMatch(settingsStylesSource, /\.settings-panel \.status-chip/)
assert.doesNotMatch(settingsStylesSource, /\.settings-card-action-button/)
assert.match(settingsStylesSource, /\.settings-action-button\b/)
assert.match(settingsStylesSource, /\.settings-actions-row\b/)
})
test("basic config action buttons share the same width rule", () => {
assert.match(
settingsStylesSource,
/\.settings-inline-save-button,\s*\n\.settings-basic-directory-actions \.settings-action-button\s*\{\s*\n\s*width:\s*96px;\s*\n\s*min-width:\s*96px;/m
)
})
test("workspace directory keeps only export diagnostics and change directory actions stacked", () => {
assert.doesNotMatch(settingsPanelsSource, />\s*恢复当前\s*</)
assert.doesNotMatch(settingsPanelsSource, />\s*保存目录\s*</)
assert.match(settingsPanelsSource, /onClick=\{\(\) => void exportDiagnostics\(\)\}/)
assert.match(settingsPanelsSource, /onClick=\{\(\) => void pickWorkspaceDirectory\(\)\}/)
assert.match(
settingsStylesSource,
/\.settings-basic-directory-actions\s*\{[\s\S]*?flex-direction:\s*column;[\s\S]*?align-items:\s*stretch;/m
)
})
test("xhs feishu actions are anchored inside the input block", () => {
assert.match(settingsPanelsSource, /className="settings-xhs-feishu-form"/)
assert.match(settingsPanelsSource, /renderActions\(\{\s*hasPending:\s*hasPendingXhsFeishuConfig,\s*onReset:\s*\(\)\s*=>\s*void onResetXhsFeishuConfig\(\),\s*onSave:\s*\(\)\s*=>\s*void onSaveXhsFeishuConfig\(\),\s*className:\s*"settings-xhs-feishu-actions"/m)
assert.match(
settingsStylesSource,
/\.settings-xhs-feishu-form\s*\{[\s\S]*?display:\s*grid;[\s\S]*?gap:\s*8px;/m
)
assert.match(
settingsStylesSource,
/\.settings-xhs-feishu-actions\s*\{[\s\S]*?justify-content:\s*flex-end;[\s\S]*?margin-top:\s*0;/m
)
})
test("settings panel actions are wired per section instead of global handlers", () => {
assert.doesNotMatch(settingsPanelsSource, /\bhasPendingSettingsChange\b/)
assert.doesNotMatch(settingsPanelsSource, /\bonSaveAll\b/)
assert.match(settingsPanelsSource, /\bonSaveLobsterKey\b/)
assert.doesNotMatch(settingsPanelsSource, /\bsaveWorkspaceDirectory\b/)
assert.doesNotMatch(settingsPanelsSource, /\brestoreWorkspaceDirectory\b/)
assert.match(settingsPanelsSource, /\bonSaveCopywritingConfig\b/)
assert.match(settingsPanelsSource, /\bonSaveImageConfig\b/)
assert.match(settingsPanelsSource, /\bonSaveVideoConfig\b/)
assert.match(settingsPanelsSource, /\bonSaveDigitalHumanConfig\b/)
assert.match(settingsPanelsSource, /\bonSaveDouyinRuntimeConfig\b/)
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment