feat(domain): improve domain form UX

- Make name field optional (defaults to domain if empty)
- Simplify domain placeholder and error messages
- Add test connection button for credential validation before save
This commit is contained in:
2026-03-12 11:03:52 +08:00
parent 8b0805bebf
commit c903733f2c
7 changed files with 289 additions and 36 deletions

View File

@@ -24,6 +24,7 @@ import type { Result } from "@renderer/types/ipc";
import type {
CreateDomainParams,
UpdateDomainParams,
TestDomainConnectionParams,
GetSpacesParams,
GetAppsParams,
GetAppDetailParams,
@@ -206,6 +207,37 @@ function registerTestConnection(): void {
});
}
/**
* Test domain connection with temporary credentials
*/
function registerTestDomainConnection(): void {
handleWithParams<TestDomainConnectionParams, boolean>(
"testDomainConnection",
async (params) => {
const tempDomain: DomainWithPassword = {
id: "temp",
name: "temp",
domain: params.domain,
username: params.username,
password: params.password || "",
authType: params.authType,
apiToken: params.apiToken,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const client = new KintoneClient(tempDomain);
const result = await client.testConnection();
if (!result.success) {
throw new Error(result.error || "Connection failed");
}
return true;
},
);
}
// ==================== Browse IPC Handlers ====================
/**
@@ -521,6 +553,7 @@ export function registerIpcHandlers(): void {
registerUpdateDomain();
registerDeleteDomain();
registerTestConnection();
registerTestDomainConnection();
// Browse
registerGetSpaces();

View File

@@ -3,6 +3,7 @@ import type {
Result,
CreateDomainParams,
UpdateDomainParams,
TestDomainConnectionParams,
GetSpacesParams,
GetAppsParams,
GetAppDetailParams,
@@ -40,6 +41,7 @@ export interface ElectronAPI {
updateDomain: (params: UpdateDomainParams) => Promise<Result<Domain>>;
deleteDomain: (id: string) => Promise<Result<void>>;
testConnection: (id: string) => Promise<Result<DomainWithStatus>>;
testDomainConnection: (params: TestDomainConnectionParams) => Promise<Result<boolean>>;
// ==================== Browse ====================
getSpaces: (params: GetSpacesParams) => Promise<Result<KintoneSpace[]>>;

View File

@@ -13,6 +13,7 @@ const api: ElectronAPI = {
updateDomain: (params) => ipcRenderer.invoke("updateDomain", params),
deleteDomain: (id) => ipcRenderer.invoke("deleteDomain", id),
testConnection: (id) => ipcRenderer.invoke("testConnection", id),
testDomainConnection: (params) => ipcRenderer.invoke("testDomainConnection", params),
// ==================== Browse ====================
getSpaces: (params) => ipcRenderer.invoke("getSpaces", params),

View File

@@ -34,6 +34,10 @@ import { DeployDialog } from "@renderer/components/DeployDialog";
const { Header, Content, Sider } = Layout;
const { Title, Text } = Typography;
// Domain section heights
const DOMAIN_SECTION_COLLAPSED = 56; // Just show current domain
const DOMAIN_SECTION_EXPANDED = 240; // Show full list
const useStyles = createStyles(({ token, css }) => ({
layout: css`
min-height: 100vh;
@@ -102,13 +106,13 @@ const useStyles = createStyles(({ token, css }) => ({
overflow: hidden;
`,
domainSection: css`
height: 200px;
border-bottom: 1px solid ${token.colorBorderSecondary};
overflow: hidden;
transition: height 0.2s ease-in-out;
`,
spaceSection: css`
height: calc(100% - 200px);
overflow: hidden;
transition: height 0.2s ease-in-out;
`,
}));
@@ -120,6 +124,11 @@ const App: React.FC = () => {
const { currentDomain } = useDomainStore();
const [deployDialogOpen, setDeployDialogOpen] = React.useState(false);
const [domainExpanded, setDomainExpanded] = React.useState(true);
const domainSectionHeight = domainExpanded
? DOMAIN_SECTION_EXPANDED
: DOMAIN_SECTION_COLLAPSED;
return (
<ConfigProvider locale={zhCN}>
@@ -132,10 +141,19 @@ const App: React.FC = () => {
<span className={styles.logoText}>Kintone Manager</span>
</div>
<div className={styles.siderContent}>
<div className={styles.domainSection}>
<DomainManager />
<div
className={styles.domainSection}
style={{ height: domainSectionHeight }}
>
<DomainManager
collapsed={!domainExpanded}
onToggleCollapse={() => setDomainExpanded(!domainExpanded)}
/>
</div>
<div className={styles.spaceSection}>
<div
className={styles.spaceSection}
style={{ height: `calc(100% - ${domainSectionHeight}px)` }}
>
<SpaceTree />
</div>
</div>

View File

@@ -64,11 +64,23 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
try {
const values = await form.validateFields();
// Process domain: remove protocol prefix and trailing slashes
let processedDomain = values.domain.trim();
if (processedDomain.startsWith("https://")) {
processedDomain = processedDomain.slice(8);
} else if (processedDomain.startsWith("http://")) {
processedDomain = processedDomain.slice(7);
}
processedDomain = processedDomain.replace(/\/+$/, "");
// Use domain as name if name is empty
const name = values.name?.trim() || processedDomain;
if (isEdit && editingDomain) {
const params: UpdateDomainParams = {
id: domainId,
name: values.name,
domain: values.domain,
name,
domain: processedDomain,
username: values.username,
authType: values.authType,
apiToken:
@@ -89,8 +101,8 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
}
} else {
const params: CreateDomainParams = {
name: values.name,
domain: values.domain,
name,
domain: processedDomain,
username: values.username,
password: values.password,
authType: values.authType,
@@ -112,6 +124,48 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
};
const authType = Form.useWatch("authType", form);
const [testing, setTesting] = React.useState(false);
// Test connection with current form values
const handleTestConnection = async () => {
try {
const values = await form.validateFields([
"domain",
"username",
"authType",
"password",
"apiToken",
]);
// Process domain
let processedDomain = values.domain.trim();
if (processedDomain.startsWith("https://")) {
processedDomain = processedDomain.slice(8);
} else if (processedDomain.startsWith("http://")) {
processedDomain = processedDomain.slice(7);
}
processedDomain = processedDomain.replace(/\/+$/, "");
setTesting(true);
const result = await window.api.testDomainConnection({
domain: processedDomain,
username: values.username,
authType: values.authType,
password: values.authType === "password" ? values.password : undefined,
apiToken: values.authType === "api_token" ? values.apiToken : undefined,
});
if (result.success) {
message.success("连接成功");
} else {
message.error(result.error || "连接失败");
}
} catch (error) {
console.error("Test connection failed:", error);
} finally {
setTesting(false);
}
};
return (
<Modal
@@ -128,13 +182,8 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
className={styles.form}
initialValues={{ authType: "password" }}
>
<Form.Item
name="name"
label="名称"
rules={[{ required: true, message: "请输入名称" }]}
>
<Input placeholder="例如:生产环境" />
</Form.Item>
<Form.Item name="name" label="名称" style={{ marginTop: 8 }}>
<Input placeholder="可选,留空则使用域名" />
<Form.Item
name="domain"
@@ -142,23 +191,35 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
rules={[
{ required: true, message: "请输入域名" },
{
pattern: /^[\w.-]+$/,
message: "请输入有效的域名例如company.kintone.com",
validator: (_, value) => {
if (!value) return Promise.resolve();
// Allow https:// or http:// prefix
let domain = value.trim();
if (domain.startsWith("https://")) {
domain = domain.slice(8);
} else if (domain.startsWith("http://")) {
domain = domain.slice(7);
}
// Remove trailing slashes
domain = domain.replace(/\/+$/, "");
// Validate domain format
if (/^[\w.-]+$/.test(domain)) {
return Promise.resolve();
}
return Promise.reject(new Error("请输入有效的域名"));
},
},
]}
>
<Input placeholder="例如:company.kintone.com" />
<Input placeholder="https://company.kintone.com" />
</Form.Item>
<Form.Item
name="username"
label="用户名"
rules={[
{ required: true, message: "请输入用户名" },
{ type: "email", message: "请输入有效的邮箱地址" },
]}
rules={[{ required: true, message: "请输入用户名" }]}
>
<Input placeholder="登录 Kintone 的邮箱地址" />
<Input placeholder="登录 Kintone 的用户名" />
</Form.Item>
<Form.Item
@@ -197,6 +258,9 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
<Form.Item style={{ marginTop: 24, marginBottom: 0 }}>
<Space style={{ width: "100%", justifyContent: "flex-end" }}>
<Button onClick={onClose}></Button>
<Button onClick={handleTestConnection} loading={testing}>
</Button>
<Button type="primary" onClick={handleSubmit} loading={loading}>
{isEdit ? "更新" : "创建"}
</Button>

View File

@@ -1,11 +1,18 @@
/**
* DomainManager Component
* Main container for domain management
* Supports collapsed/expanded view
*/
import React from "react";
import { Button, Empty, Spin } from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import { Button, Empty, Spin, Tooltip, Avatar, Space } from "antd";
import {
PlusOutlined,
ReloadOutlined,
CloudServerOutlined,
UpOutlined,
DownOutlined,
} from "@ant-design/icons";
import { createStyles } from "antd-style";
import { useDomainStore } from "@renderer/stores";
import DomainList from "./DomainList";
@@ -19,6 +26,12 @@ const useStyles = createStyles(({ token, css }) => ({
padding: ${token.paddingLG}px;
background: ${token.colorBgContainer};
`,
collapsedContainer: css`
height: 100%;
display: flex;
flex-direction: column;
background: ${token.colorBgContainer};
`,
header: css`
display: flex;
justify-content: space-between;
@@ -47,11 +60,61 @@ const useStyles = createStyles(({ token, css }) => ({
align-items: center;
height: 200px;
`,
collapsedHeader: css`
display: flex;
justify-content: space-between;
align-items: center;
padding: ${token.paddingSM}px ${token.paddingMD}px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: ${token.colorBgTextHover};
}
`,
collapsedInfo: css`
display: flex;
align-items: center;
gap: ${token.paddingSM}px;
flex: 1;
min-width: 0;
`,
collapsedName: css`
font-weight: ${token.fontWeightStrong};
font-size: ${token.fontSize}px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`,
collapsedUrl: css`
color: ${token.colorTextSecondary};
font-size: ${token.fontSizeSM}px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`,
collapsedActions: css`
display: flex;
gap: ${token.paddingXS}px;
`,
noDomainText: css`
color: ${token.colorTextSecondary};
font-size: ${token.fontSizeSM}px;
padding: ${token.paddingSM}px ${token.paddingMD}px;
`,
}));
const DomainManager: React.FC = () => {
interface DomainManagerProps {
collapsed?: boolean;
onToggleCollapse?: () => void;
}
const DomainManager: React.FC<DomainManagerProps> = ({
collapsed = false,
onToggleCollapse,
}) => {
const { styles } = useStyles();
const { domains, loading, loadDomains } = useDomainStore();
const { domains, loading, loadDomains, currentDomain } = useDomainStore();
const [formOpen, setFormOpen] = React.useState(false);
const [editingDomain, setEditingDomain] = React.useState<string | null>(null);
@@ -78,20 +141,83 @@ const DomainManager: React.FC = () => {
loadDomains();
};
// Collapsed view - show current domain only
if (collapsed) {
return (
<div className={styles.collapsedContainer}>
<div className={styles.collapsedHeader} onClick={onToggleCollapse}>
<div className={styles.collapsedInfo}>
<Avatar
icon={<CloudServerOutlined />}
size="small"
style={{
backgroundColor: currentDomain ? "#1890ff" : "#d9d9d9",
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
{currentDomain ? (
<>
<div className={styles.collapsedName}>
{currentDomain.name}
</div>
<div className={styles.collapsedUrl}>
{currentDomain.domain}
</div>
</>
) : (
<div className={styles.collapsedName}> Domain</div>
)}
</div>
</div>
<Space>
<Tooltip title="添加 Domain">
<Button
type="text"
size="small"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation();
handleAdd();
}}
/>
</Tooltip>
<Tooltip title="展开">
<Button
type="text"
size="small"
icon={<DownOutlined />}
/>
</Tooltip>
</Space>
</div>
<DomainForm
open={formOpen}
onClose={handleCloseForm}
domainId={editingDomain}
/>
</div>
);
}
// Expanded view - full list
return (
<div className={styles.container}>
<div className={styles.header}>
<h2 className={styles.title}>Domain </h2>
<div className={styles.actions}>
<Button
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={loading}
>
</Button>
<Tooltip title="收起">
<Button icon={<UpOutlined />} onClick={onToggleCollapse} />
</Tooltip>
<Tooltip title="刷新">
<Button
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={loading}
/>
</Tooltip>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
Domain
</Button>
</div>
</div>

View File

@@ -38,6 +38,14 @@ export interface UpdateDomainParams {
apiToken?: string;
}
export interface TestDomainConnectionParams {
domain: string;
username: string;
authType: "password" | "api_token";
password?: string;
apiToken?: string;
}
// ==================== Browse IPC Types ====================
export interface GetSpacesParams {
@@ -126,6 +134,7 @@ export interface ElectronAPI {
updateDomain: (params: UpdateDomainParams) => Promise<Result<Domain>>;
deleteDomain: (id: string) => Promise<Result<void>>;
testConnection: (id: string) => Promise<Result<DomainWithStatus>>;
testDomainConnection: (params: TestDomainConnectionParams) => Promise<Result<boolean>>;
// Browse
getSpaces: (params: GetSpacesParams) => Promise<Result<KintoneSpace[]>>;