This commit is contained in:
2026-03-16 13:42:29 +08:00
parent e823e703ea
commit b34720fccf
13 changed files with 428 additions and 282 deletions

11
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"i18n-ally.localesPaths": [
"src/renderer/src/locales"
],
"i18n-ally.keystyle": "nested",
"i18n-ally.namespace": true,
"i18n-ally.sourceLanguage": "zh-CN",
"i18n-ally.enabledParsers": [
"json"
],
}

42
MEMORY.md Normal file
View File

@@ -0,0 +1,42 @@
# MEMORY.md
## 2026-03-15 - CSS 模板字符串语法错误
### 遇到什么问题
- 在使用 `edit` 工具修改 `DomainForm.tsx` 中的 CSS 样式时,只替换了部分内容,导致 CSS 模板字符串语法错误
- 错误信息:`Unexpected token, expected ","``passwordHint` 定义处
- 原因:`.ant-form-item` 的 CSS 块没有正确关闭,缺少 `}` 和模板字符串结束符 `` ` ``
### 如何解决的
- 使用 `edit` 工具完整替换整个 `useStyles` 定义块,确保所有 CSS 模板字符串正确关闭
### 以后如何避免
- 修改 CSS-in-JS 样式时,尽量替换完整的样式块而非单行
- 修改后立即运行 `npx tsc --noEmit` 验证语法
- 注意模板字符串的开始 `` ` `` 和结束 `` ` `` 必须成对出现
---
## 2026-03-15 - UI 重构经验
### 变更内容
1. **DomainForm 表单间距**`marginMD``marginSM`
2. **AppDetail 头部布局**标题和按钮同一行flex 布局)
3. **AppDetail Tabs 重构**
- 移除 Tabs 组件
- 移除 "基本信息" tab
- 合并 4 个 JS/CSS tab 为单页面(选项 A单列滚动列表 + 分区标题)
- 新增 `viewMode` 状态管理列表/代码视图切换
- 点击文件进入代码视图,带返回按钮
### 文件修改
- `src/renderer/src/components/DomainManager/DomainForm.tsx`
- `src/renderer/src/components/AppDetail/AppDetail.tsx`
- `src/renderer/src/locales/zh-CN/app.json` - 添加 `backToList`
- `src/renderer/src/locales/en-US/app.json` - 添加 `backToList`
- `src/renderer/src/locales/ja-JP/app.json` - 添加 `backToList`

View File

@@ -31,6 +31,13 @@ function createWindow(): void {
mainWindow.show(); mainWindow.show();
}); });
// 阻止 Alt 键显示默认菜单栏
mainWindow.webContents.on("before-input-event", (event, input) => {
if (input.key === "Alt" && input.type === "keyDown") {
event.preventDefault();
}
});
mainWindow.webContents.setWindowOpenHandler((details) => { mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url); shell.openExternal(details.url);
return { action: "deny" }; return { action: "deny" };

View File

@@ -105,9 +105,13 @@ const useStyles = createStyles(({ token, css }) => ({
white-space: nowrap; white-space: nowrap;
`, `,
emptySection: css` emptySection: css`
height: 100%;
padding: ${token.paddingLG}px; padding: ${token.paddingLG}px;
text-align: center; text-align: center;
color: ${token.colorTextSecondary}; color: ${token.colorTextSecondary};
display: flex;
justify-content: center;
align-items: center;
`, `,
sectionHeader: css` sectionHeader: css`
display: flex; display: flex;
@@ -199,7 +203,6 @@ const AppDetail: React.FC = () => {
<div className={styles.emptySection}> <div className={styles.emptySection}>
<Empty <Empty
description={t("selectApp")} description={t("selectApp")}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/> />
</div> </div>
</div> </div>
@@ -220,7 +223,6 @@ const AppDetail: React.FC = () => {
<div className={styles.emptySection}> <div className={styles.emptySection}>
<Empty <Empty
description={t("appNotFound")} description={t("appNotFound")}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/> />
</div> </div>
</div> </div>

View File

@@ -429,7 +429,6 @@ const AppList: React.FC = () => {
<div className={styles.empty}> <div className={styles.empty}>
<Empty <Empty
description={t("noApps")} description={t("noApps")}
image={Empty.PRESENTED_IMAGE_SIMPLE}
> >
<Button type="primary" onClick={handleLoadApps}> <Button type="primary" onClick={handleLoadApps}>
{t("loadApps")} {t("loadApps")}

View File

@@ -172,7 +172,6 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
<div className={styles.container}> <div className={styles.container}>
<Empty <Empty
description={t("fileEmpty")} description={t("fileEmpty")}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/> />
</div> </div>
); );

View File

@@ -5,24 +5,11 @@
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button, Form } from "@lobehub/ui"; import { Button, Input, InputPassword, Modal } from "@lobehub/ui";
import { Input, message, Modal } from "antd"; import { Form } from "antd";
import { createStyles } from "antd-style";
import { useDomainStore } from "@renderer/stores"; import { useDomainStore } from "@renderer/stores";
import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc"; import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc";
import { CheckCircle2, XCircle } from "lucide-react";
const useStyles = createStyles(({ token, css }) => ({
form: css`
.ant-form-item {
margin-bottom: ${token.marginSM}px;
}
`,
passwordHint: css`
color: ${token.colorTextSecondary};
font-size: ${token.fontSizeSM}px;
margin-top: ${token.marginXXS}px;
`,
}));
interface DomainFormProps { interface DomainFormProps {
open: boolean; open: boolean;
@@ -30,20 +17,55 @@ interface DomainFormProps {
domainId: string | null; domainId: string | null;
} }
// Connection test result state
type TestResult = {
success: boolean;
message?: string;
} | null;
// Test connection parameters type
export type TestConnectionParams = {
domain: string;
username: string;
password: string;
processedDomain: string;
};
// Create error type
type CreateErrorType = "connection" | "duplicate" | "unknown" | null;
const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => { const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
const { t } = useTranslation("domain"); const { t } = useTranslation("domain");
const { styles } = useStyles();
const [form] = Form.useForm(); const [form] = Form.useForm();
const { domains, createDomain, updateDomainById, loading } = useDomainStore(); const { domains, createDomain, updateDomainById } = useDomainStore();
const isEdit = !!domainId; const isEdit = !!domainId;
const editingDomain = domainId const editingDomain = domainId
? domains.find((d) => d.id === domainId) ? domains.find((d) => d.id === domainId)
: null; : null;
// Test connection state
const [testing, setTesting] = React.useState(false);
const [testResult, setTestResult] = React.useState<TestResult>(null);
// Submit state (separate from testing)
const [submitting, setSubmitting] = React.useState(false);
// Create error state
const [createError, setCreateError] = React.useState<{
type: CreateErrorType;
message: string;
} | null>(null);
// Reset form when dialog opens // Reset form when dialog opens
React.useEffect(() => { React.useEffect(() => {
if (open) { if (open) {
// Reset states
setTestResult(null);
setCreateError(null);
setSubmitting(false);
setTesting(false);
if (editingDomain) { if (editingDomain) {
form.setFieldsValue({ form.setFieldsValue({
name: editingDomain.name, name: editingDomain.name,
@@ -61,22 +83,156 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
} }
}, [open, editingDomain, form]); }, [open, editingDomain, form]);
// Clear test result when form values change
const handleFieldChange = () => {
if (testResult) {
setTestResult(null);
}
if (createError) {
setCreateError(null);
}
};
/**
* Process domain: remove protocol prefix and trailing slashes
*/
const processDomain = (domain: string): string => {
let processed = domain.trim();
if (processed.startsWith("https://")) {
processed = processed.slice(8);
} else if (processed.startsWith("http://")) {
processed = processed.slice(7);
}
return processed.replace(/\/+$/, "");
};
/**
* Validate domain format
*/
const validateDomainFormat = (domain: string): boolean => {
return /^[\w.-]+$/.test(domain);
};
/**
* Get form values for domain connection test
*/
const getConnectionParams =
async (): Promise<TestConnectionParams | null> => {
try {
const values = await form.validateFields([
"domain",
"username",
"password",
]);
const processedDomain = processDomain(values.domain);
if (!validateDomainFormat(processedDomain)) {
return null;
}
return {
domain: values.domain,
username: values.username,
password: values.password,
processedDomain,
};
} catch {
return null;
}
};
/**
* Test connection with current form values
*/
const testConnection = async (
params: TestConnectionParams,
): Promise<TestResult> => {
if (!params) {
return { success: false, message: t("validDomainRequired") };
}
try {
const result = await window.api.testDomainConnection({
domain: params.processedDomain,
username: params.username,
password: params.password,
});
if (result.success) {
return { success: true, message: t("connectionSuccess") };
} else {
return {
success: false,
message: result.error || t("connectionFailed"),
};
}
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : t("connectionFailed"),
};
}
};
/**
* Handle test connection button click
*/
const handleTestConnection = async () => {
// Clear previous result
setTestResult(null);
setCreateError(null);
const params = await getConnectionParams();
if (!params) return;
setTesting(true);
const result = await testConnection(params);
setTestResult(result);
setTesting(false);
};
/**
* Show error message
*/
const showError = (type: CreateErrorType, message: string) => {
setCreateError({
type,
message,
});
};
/**
* Handle form submission
*/
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
const processedDomain = processDomain(values.domain);
// 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; const name = values.name?.trim() || processedDomain;
// Clear previous error
setCreateError(null);
// For new domains, test connection first
if (!isEdit) {
setSubmitting(true);
const testResult = await testConnection({
domain: values.domain,
username: values.username,
password: values.password,
processedDomain,
});
if (!testResult?.success) {
showError("connection", t("createConnectionFailed"));
setSubmitting(false);
return;
}
} else {
setSubmitting(true);
}
if (isEdit && editingDomain) { if (isEdit && editingDomain) {
const params: UpdateDomainParams = { const params: UpdateDomainParams = {
id: domainId, id: domainId,
@@ -85,19 +241,31 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
username: values.username, username: values.username,
}; };
// Only include password if provided
if (values.password) { if (values.password) {
params.password = values.password; params.password = values.password;
} }
const success = await updateDomainById(params); const success = await updateDomainById(params);
setSubmitting(false);
if (success) { if (success) {
message.success(t("domainUpdated"));
onClose(); onClose();
} else { } else {
message.error(t("updateFailed")); showError("unknown", t("updateFailed"));
} }
} else { } else {
// Check for duplicate before creating
const existingDomain = domains.find(
(d) =>
d.domain.toLowerCase() === processedDomain.toLowerCase() &&
d.username.toLowerCase() === values.username.toLowerCase(),
);
if (existingDomain) {
showError("duplicate", t("domainDuplicate"));
setSubmitting(false);
return;
}
const params: CreateDomainParams = { const params: CreateDomainParams = {
name, name,
domain: processedDomain, domain: processedDomain,
@@ -106,55 +274,37 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
}; };
const success = await createDomain(params); const success = await createDomain(params);
setSubmitting(false);
if (success) { if (success) {
message.success(t("domainCreated"));
onClose(); onClose();
} else { } else {
message.error(t("createFailed")); showError("unknown", t("createFailed"));
} }
} }
} catch (error) { } catch (error) {
setSubmitting(false);
console.error("Form validation failed:", error); console.error("Form validation failed:", error);
} }
}; };
const [testing, setTesting] = React.useState(false); /**
* Render test button with result icon inside
*/
const renderTestButton = () => {
const getIcon = () => {
if (!testResult) return undefined;
return testResult.success ? (
<CheckCircle2 size={16} color="#52c41a" />
) : (
<XCircle size={16} color="#ff4d4f" />
);
};
// Test connection with current form values return (
const handleTestConnection = async () => { <Button onClick={handleTestConnection} loading={testing} icon={getIcon()}>
try { {t("testConnection")}
const values = await form.validateFields([ </Button>
"domain", );
"username",
"password",
]);
// 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,
password: values.password,
});
if (result.success) {
message.success(t("connectionSuccess"));
} else {
message.error(result.error || t("connectionFailed"));
}
} catch (error) {
console.error("Test connection failed:", error);
} finally {
setTesting(false);
}
}; };
return ( return (
@@ -167,7 +317,7 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
destroyOnHidden destroyOnHidden
mask={{ closable: false }} mask={{ closable: false }}
> >
<Form form={form} layout="vertical" className={styles.form}> <Form form={form} layout="vertical" onValuesChange={handleFieldChange}>
<Form.Item name="name" label={t("name")} style={{ marginTop: 8 }}> <Form.Item name="name" label={t("name")} style={{ marginTop: 8 }}>
<Input placeholder={t("nameOptional")} /> <Input placeholder={t("nameOptional")} />
</Form.Item> </Form.Item>
@@ -180,17 +330,8 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
{ {
validator: (_, value) => { validator: (_, value) => {
if (!value) return Promise.resolve(); if (!value) return Promise.resolve();
// Allow https:// or http:// prefix const processed = processDomain(value);
let domain = value.trim(); if (validateDomainFormat(processed)) {
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.resolve();
} }
return Promise.reject(new Error(t("validDomainRequired"))); return Promise.reject(new Error(t("validDomainRequired")));
@@ -212,21 +353,42 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
<Form.Item <Form.Item
name="password" name="password"
label={t("password")} label={t("password")}
rules={isEdit ? [] : [{ required: true, message: t("enterPassword") }]} rules={
isEdit ? [] : [{ required: true, message: t("enterPassword") }]
}
> >
<Input.Password <InputPassword
placeholder={isEdit ? t("keepPasswordHint") : t("enterPassword")} placeholder={isEdit ? t("keepPasswordHint") : t("enterPassword")}
/> />
</Form.Item> </Form.Item>
<Form.Item style={{ marginTop: 24, marginBottom: 0 }}> <Form.Item style={{ marginTop: 32, marginBottom: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}> <div
<Button onClick={handleTestConnection} loading={testing}> style={{
{t("testConnection")} display: "flex",
</Button> justifyContent: "space-between",
<div style={{ display: "flex", gap: 8 }}> gap: 8,
alignItems: "center",
}}
>
{/* Left side: Cancel button and error message */}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Button onClick={onClose}>{t("cancel", { ns: "common" })}</Button> <Button onClick={onClose}>{t("cancel", { ns: "common" })}</Button>
<Button type="primary" onClick={handleSubmit} loading={loading}> </div>
{/* Right side: Test button and Create/Update button */}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{createError && (
<span style={{ color: "#ff4d4f", fontSize: 14 }}>
{createError.message}
</span>
)}
{renderTestButton()}
<Button
type="primary"
onClick={handleSubmit}
loading={submitting}
>
{isEdit ? t("update") : t("create")} {isEdit ? t("update") : t("create")}
</Button> </Button>
</div> </div>

View File

@@ -1,33 +1,23 @@
/** /**
* DomainList Component * DomainList Component
* Displays list of domains with connection status and drag-to-reorder * Displays list of domains with drag-to-reorder functionality
*/ */
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Tag, Popconfirm, Space } from "antd"; import { Popconfirm, Space } from "antd";
import { Button, Tooltip } from "@lobehub/ui"; import { Button, SortableList, Tooltip } from "@lobehub/ui";
import { Avatar } from "@lobehub/ui"; import { Pencil, Trash2 } from "lucide-react";
import {
Cloud,
Pencil,
Trash2,
CheckCircle,
XCircle,
HelpCircle,
GripVertical,
} from "lucide-react";
import { createStyles } from "antd-style"; import { createStyles } from "antd-style";
import { useDomainStore, useUIStore } from "@renderer/stores"; import { useDomainStore } from "@renderer/stores";
import type { Domain } from "@shared/types/domain"; import type { Domain } from "@shared/types/domain";
const useStyles = createStyles(({ token, css }) => ({ const useStyles = createStyles(({ token, css }) => ({
item: css` itemWrapper: css`
padding: ${token.paddingSM}px; width: 100%;
padding: ${token.paddingXS}px ${token.paddingSM}px;
border-radius: ${token.borderRadiusLG}px; border-radius: ${token.borderRadiusLG}px;
background: ${token.colorBgContainer}; background: ${token.colorBgContainer};
border: 1px solid ${token.colorBorderSecondary}; border: 1px solid ${token.colorBorderSecondary};
margin-bottom: ${token.marginXS}px;
transition: all 0.2s; transition: all 0.2s;
cursor: pointer; cursor: pointer;
@@ -35,15 +25,17 @@ const useStyles = createStyles(({ token, css }) => ({
border-color: ${token.colorPrimary}; border-color: ${token.colorPrimary};
box-shadow: ${token.boxShadowSecondary}; box-shadow: ${token.boxShadowSecondary};
} }
&:hover .domain-item-actions {
opacity: 1;
}
`, `,
selectedItem: css` selectedItem: css`
border-color: ${token.colorPrimary}; border-color: ${token.colorPrimary};
background: ${token.colorPrimaryBg}; background: ${token.colorPrimaryBg};
`, `,
itemDragging: css`
opacity: 0.5;
border: 2px dashed ${token.colorPrimary};
`,
domainInfo: css` domainInfo: css`
display: flex; display: flex;
align-items: center; align-items: center;
@@ -51,6 +43,7 @@ const useStyles = createStyles(({ token, css }) => ({
flex: 1; flex: 1;
min-width: 0; min-width: 0;
`, `,
domainName: css` domainName: css`
font-weight: ${token.fontWeightStrong}; font-weight: ${token.fontWeightStrong};
font-size: ${token.fontSizeLG}px; font-size: ${token.fontSizeLG}px;
@@ -58,6 +51,7 @@ const useStyles = createStyles(({ token, css }) => ({
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
`, `,
domainUrl: css` domainUrl: css`
color: ${token.colorTextSecondary}; color: ${token.colorTextSecondary};
font-size: ${token.fontSizeSM}px; font-size: ${token.fontSizeSM}px;
@@ -65,33 +59,29 @@ const useStyles = createStyles(({ token, css }) => ({
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
`, `,
actions: css` actions: css`
position: absolute;
right: ${token.paddingXS}px;
top: 50%;
transform: translateY(-50%);
display: flex; display: flex;
gap: ${token.paddingXS}px; gap: ${token.paddingXS}px;
opacity: 0;
transition: opacity 0.2s;
background: ${token.colorBgContainer};
border-radius: ${token.borderRadiusSM}px;
padding: 2px;
box-shadow: ${token.boxShadowSecondary};
`, `,
statusTag: css`
margin-left: ${token.paddingSM}px;
`,
dragHandle: css`
cursor: grab;
color: ${token.colorTextTertiary};
display: flex;
align-items: center;
&:hover {
color: ${token.colorText};
}
&:active {
cursor: grabbing;
}
`,
itemContent: css` itemContent: css`
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: ${token.paddingSM}px; gap: ${token.paddingXS}px;
position: relative;
`, `,
domainText: css` domainText: css`
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -106,17 +96,8 @@ interface DomainListProps {
const DomainList: React.FC<DomainListProps> = ({ onEdit }) => { const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
const { t } = useTranslation("domain"); const { t } = useTranslation("domain");
const { styles } = useStyles(); const { styles } = useStyles();
const { const { domains, currentDomain, switchDomain, deleteDomain, reorderDomains } =
domains, useDomainStore();
currentDomain,
connectionStatuses,
switchDomain,
deleteDomain,
testConnection,
reorderDomains,
} = useDomainStore();
const { domainIconColors } = useUIStore();
const [draggingIndex, setDraggingIndex] = React.useState<number | null>(null);
const handleSelect = (domain: Domain) => { const handleSelect = (domain: Domain) => {
switchDomain(domain); switchDomain(domain);
@@ -126,131 +107,69 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
await deleteDomain(id); await deleteDomain(id);
}; };
const getStatusIcon = (id: string) => { // Handle reorder - convert SortableListItem[] back to reorder action
const status = connectionStatuses[id]; const handleSortChange = (newItems: { id: string }[]) => {
switch (status) { const newOrder = newItems.map((item) => item.id);
case "connected": const oldOrder = domains.map((d) => d.id);
return <CheckCircle size={16} style={{ color: "#52c41a" }} />;
case "error": // Find the from and to indices
return <XCircle size={16} style={{ color: "#ff4d4f" }} />; for (let i = 0; i < oldOrder.length; i++) {
default: if (oldOrder[i] !== newOrder[i]) {
return <HelpCircle size={16} style={{ color: "#faad14" }} />; const fromIndex = i;
const toIndex = newOrder.indexOf(oldOrder[i]);
reorderDomains(fromIndex, toIndex);
break;
}
} }
}; };
const getStatusTag = (id: string) => { const renderItem = (item: { id: string }) => {
const status = connectionStatuses[id]; const domain = domains.find((d) => d.id === item.id);
switch (status) { if (!domain) return null;
case "connected":
return <Tag color="success">{t("connected")}</Tag>;
case "error":
return <Tag color="error">{t("connectionFailed")}</Tag>;
default:
return <Tag color="warning">{t("notTested")}</Tag>;
}
};
const getDomainIconColor = (domainId: string, isSelected: boolean) => { const isSelected = currentDomain?.id === domain.id;
if (domainIconColors[domainId]) {
return domainIconColors[domainId];
}
return isSelected ? "#1890ff" : "#87d068";
};
// Drag and drop handlers return (
const handleDragStart = (index: number) => { <SortableList.Item id={domain.id}>
setDraggingIndex(index); <div
}; className={`${styles.itemWrapper} ${isSelected ? styles.selectedItem : ""}`}
onClick={() => handleSelect(domain)}
const handleDragOver = (e: React.DragEvent, index: number) => { >
e.preventDefault(); <div className={styles.itemContent}>
if (draggingIndex === null || draggingIndex === index) return; <SortableList.DragHandle />
}; <div className={styles.domainInfo}>
<div className={styles.domainText}>
const handleDrop = (e: React.DragEvent, index: number) => { <div className={styles.domainName}>{domain.name}</div>
e.preventDefault(); <div className={styles.domainUrl}>
if (draggingIndex === null || draggingIndex === index) return; {domain.username} · {domain.domain}
reorderDomains(draggingIndex, index);
setDraggingIndex(null);
};
const handleDragEnd = () => {
setDraggingIndex(null);
};
return (
<>
{domains.map((domain, index) => {
const isSelected = currentDomain?.id === domain.id;
const isDragging = draggingIndex === index;
return (
<div
key={domain.id}
className={`${styles.item} ${isSelected ? styles.selectedItem : ""} ${isDragging ? styles.itemDragging : ""}`}
onClick={() => handleSelect(domain)}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
>
<div className={styles.itemContent}>
<div className={styles.dragHandle}>
<GripVertical size={16} />
</div>
<div className={styles.domainInfo}>
<Avatar
icon={<Cloud size={16} />}
style={{
backgroundColor: getDomainIconColor(domain.id, isSelected),
}}
/>
<div className={styles.domainText}>
<div className={styles.domainName}>
{domain.name}
{getStatusTag(domain.id)}
</div>
<div className={styles.domainUrl}>
{domain.domain} · {domain.username}
</div>
</div> </div>
</div> </div>
</div>
<Space> <Space className={"domain-item-actions " + styles.actions}>
<Tooltip title={t("testConnection")}> <Tooltip title={t("edit")}>
<Button <Button
type="text" type="text"
size="small" size="small"
icon={getStatusIcon(domain.id)} icon={<Pencil size={16} />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
testConnection(domain.id); onEdit(domain.id);
}}
/>
</Tooltip>
<Tooltip title={t("edit")}>
<Button
type="text"
size="small"
icon={<Pencil size={16} />}
onClick={(e) => {
e.stopPropagation();
onEdit(domain.id);
}}
/>
</Tooltip>
<Popconfirm
title={t("confirmDelete")}
description={t("confirmDeleteDesc")}
onConfirm={(e) => {
e?.stopPropagation();
handleDelete(domain.id);
}} }}
onCancel={(e) => e?.stopPropagation()} />
okText={t("delete", { ns: "common" })} </Tooltip>
cancelText={t("cancel", { ns: "common" })} <Popconfirm
> title={t("confirmDelete")}
description={t("confirmDeleteDesc")}
onConfirm={(e) => {
e?.stopPropagation();
handleDelete(domain.id);
}}
onCancel={(e) => e?.stopPropagation()}
okText={t("delete", { ns: "common" })}
cancelText={t("cancel", { ns: "common" })}
>
<Tooltip title={t("delete", { ns: "common" })}>
<Button <Button
type="text" type="text"
size="small" size="small"
@@ -258,13 +177,22 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
icon={<Trash2 size={16} />} icon={<Trash2 size={16} />}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
</Popconfirm> </Tooltip>
</Space> </Popconfirm>
</div> </Space>
</div> </div>
); </div>
})} </SortableList.Item>
</> );
};
return (
<SortableList
items={domains}
renderItem={renderItem}
onChange={handleSortChange}
gap={4}
/>
); );
}; };

View File

@@ -38,10 +38,9 @@ const useStyles = createStyles(({ token, css }) => ({
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: ${token.marginLG}px; margin-bottom: ${token.marginXXS}px;
padding: 0 ${token.paddingLG}px; padding: 0 ${token.paddingLG}px;
padding-top: ${token.paddingLG}px; padding-top: ${token.paddingXS}px;
border-bottom: 1px solid ${token.colorBorderSecondary};
`, `,
title: css` title: css`
font-size: ${token.fontSizeHeading4}px; font-size: ${token.fontSizeHeading4}px;
@@ -56,7 +55,7 @@ const useStyles = createStyles(({ token, css }) => ({
content: css` content: css`
flex: 1; flex: 1;
overflow: auto; overflow: auto;
padding: 0 ${token.paddingLG}px; padding: 0 ${token.paddingSM}px;
`, `,
loading: css` loading: css`
display: flex; display: flex;
@@ -194,7 +193,7 @@ const DomainManager: React.FC<DomainManagerProps> = ({
{currentDomain.name} {currentDomain.name}
</div> </div>
<div className={styles.collapsedUrl}> <div className={styles.collapsedUrl}>
{currentDomain.domain} {currentDomain.username} · {currentDomain.domain}
</div> </div>
</> </>
) : ( ) : (
@@ -256,11 +255,7 @@ const DomainManager: React.FC<DomainManagerProps> = ({
) : domains.length === 0 ? ( ) : domains.length === 0 ? (
<Empty <Empty
description={t("noDomainConfig")} description={t("noDomainConfig")}
image={Empty.PRESENTED_IMAGE_SIMPLE}
> >
<Button type="primary" onClick={handleAdd}>
{t("addFirstDomainConfig")}
</Button>
</Empty> </Empty>
) : ( ) : (
<DomainList onEdit={handleEdit} /> <DomainList onEdit={handleEdit} />

View File

@@ -194,7 +194,6 @@ const VersionHistory: React.FC = () => {
<div style={{ padding: 24, textAlign: "center" }}> <div style={{ padding: 24, textAlign: "center" }}>
<Empty <Empty
description={t("selectApp", { ns: "app" })} description={t("selectApp", { ns: "app" })}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/> />
</div> </div>
</div> </div>
@@ -230,7 +229,6 @@ const VersionHistory: React.FC = () => {
{versions.length === 0 ? ( {versions.length === 0 ? (
<Empty <Empty
description={t("noVersions")} description={t("noVersions")}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/> />
) : ( ) : (
<List <List

View File

@@ -33,7 +33,6 @@
"domainManagement": "Domain Management", "domainManagement": "Domain Management",
"add": "Add", "add": "Add",
"noDomainConfig": "No Domain configuration", "noDomainConfig": "No Domain configuration",
"addFirstDomainConfig": "Add first Domain",
"noDomainSelected": "No Domain selected", "noDomainSelected": "No Domain selected",
"edit": "Edit", "edit": "Edit",
"testConnection": "Test Connection", "testConnection": "Test Connection",
@@ -55,6 +54,8 @@
"update": "Update", "update": "Update",
"domainCreated": "Domain created successfully", "domainCreated": "Domain created successfully",
"domainUpdated": "Domain updated successfully", "domainUpdated": "Domain updated successfully",
"createConnectionFailed": "Connection failed during creation",
"domainDuplicate": "This domain and username combination already exists",
"createFailed": "Creation failed", "createFailed": "Creation failed",
"updateFailed": "Update failed" "updateFailed": "Update failed"
} }

View File

@@ -33,7 +33,6 @@
"domainManagement": "ドメイン管理", "domainManagement": "ドメイン管理",
"add": "追加", "add": "追加",
"noDomainConfig": "ドメイン設定がありません", "noDomainConfig": "ドメイン設定がありません",
"addFirstDomainConfig": "最初のドメインを追加",
"noDomainSelected": "ドメイン未選択", "noDomainSelected": "ドメイン未選択",
"edit": "編集", "edit": "編集",
"testConnection": "接続テスト", "testConnection": "接続テスト",
@@ -55,6 +54,8 @@
"update": "更新", "update": "更新",
"domainCreated": "ドメインを作成しました", "domainCreated": "ドメインを作成しました",
"domainUpdated": "ドメインを更新しました", "domainUpdated": "ドメインを更新しました",
"createConnectionFailed": "接続に失敗しました",
"domainDuplicate": "このドメインとユーザー名の組み合わせは既に存在します",
"createFailed": "作成に失敗しました", "createFailed": "作成に失敗しました",
"updateFailed": "更新に失敗しました" "updateFailed": "更新に失敗しました"
} }

View File

@@ -33,7 +33,6 @@
"domainManagement": "Domain 管理", "domainManagement": "Domain 管理",
"add": "添加", "add": "添加",
"noDomainConfig": "暂无 Domain 配置", "noDomainConfig": "暂无 Domain 配置",
"addFirstDomainConfig": "添加第一个 Domain",
"noDomainSelected": "未选择 Domain", "noDomainSelected": "未选择 Domain",
"edit": "编辑", "edit": "编辑",
"testConnection": "测试连接", "testConnection": "测试连接",
@@ -55,6 +54,8 @@
"update": "更新", "update": "更新",
"domainCreated": "Domain 创建成功", "domainCreated": "Domain 创建成功",
"domainUpdated": "Domain 更新成功", "domainUpdated": "Domain 更新成功",
"createConnectionFailed": "创建时连接失败",
"domainDuplicate": "该域名和用户名组合已存在",
"createFailed": "创建失败", "createFailed": "创建失败",
"updateFailed": "更新失败" "updateFailed": "更新失败"
} }