fix UI
This commit is contained in:
11
.vscode/settings.json
vendored
Normal file
11
.vscode/settings.json
vendored
Normal 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
42
MEMORY.md
Normal 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`
|
||||
@@ -31,6 +31,13 @@ function createWindow(): void {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
// 阻止 Alt 键显示默认菜单栏
|
||||
mainWindow.webContents.on("before-input-event", (event, input) => {
|
||||
if (input.key === "Alt" && input.type === "keyDown") {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url);
|
||||
return { action: "deny" };
|
||||
|
||||
@@ -105,9 +105,13 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
white-space: nowrap;
|
||||
`,
|
||||
emptySection: css`
|
||||
height: 100%;
|
||||
padding: ${token.paddingLG}px;
|
||||
text-align: center;
|
||||
color: ${token.colorTextSecondary};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`,
|
||||
sectionHeader: css`
|
||||
display: flex;
|
||||
@@ -199,7 +203,6 @@ const AppDetail: React.FC = () => {
|
||||
<div className={styles.emptySection}>
|
||||
<Empty
|
||||
description={t("selectApp")}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,7 +223,6 @@ const AppDetail: React.FC = () => {
|
||||
<div className={styles.emptySection}>
|
||||
<Empty
|
||||
description={t("appNotFound")}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -429,7 +429,6 @@ const AppList: React.FC = () => {
|
||||
<div className={styles.empty}>
|
||||
<Empty
|
||||
description={t("noApps")}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Button type="primary" onClick={handleLoadApps}>
|
||||
{t("loadApps")}
|
||||
|
||||
@@ -172,7 +172,6 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
|
||||
<div className={styles.container}>
|
||||
<Empty
|
||||
description={t("fileEmpty")}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,24 +5,11 @@
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Form } from "@lobehub/ui";
|
||||
import { Input, message, Modal } from "antd";
|
||||
import { createStyles } from "antd-style";
|
||||
import { Button, Input, InputPassword, Modal } from "@lobehub/ui";
|
||||
import { Form } from "antd";
|
||||
import { useDomainStore } from "@renderer/stores";
|
||||
import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc";
|
||||
|
||||
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;
|
||||
`,
|
||||
}));
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
interface DomainFormProps {
|
||||
open: boolean;
|
||||
@@ -30,20 +17,55 @@ interface DomainFormProps {
|
||||
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 { t } = useTranslation("domain");
|
||||
const { styles } = useStyles();
|
||||
const [form] = Form.useForm();
|
||||
const { domains, createDomain, updateDomainById, loading } = useDomainStore();
|
||||
const { domains, createDomain, updateDomainById } = useDomainStore();
|
||||
|
||||
const isEdit = !!domainId;
|
||||
const editingDomain = domainId
|
||||
? domains.find((d) => d.id === domainId)
|
||||
: 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
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
// Reset states
|
||||
setTestResult(null);
|
||||
setCreateError(null);
|
||||
setSubmitting(false);
|
||||
setTesting(false);
|
||||
|
||||
if (editingDomain) {
|
||||
form.setFieldsValue({
|
||||
name: editingDomain.name,
|
||||
@@ -61,22 +83,156 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
|
||||
}
|
||||
}, [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 () => {
|
||||
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 processedDomain = processDomain(values.domain);
|
||||
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) {
|
||||
const params: UpdateDomainParams = {
|
||||
id: domainId,
|
||||
@@ -85,19 +241,31 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
|
||||
username: values.username,
|
||||
};
|
||||
|
||||
// Only include password if provided
|
||||
if (values.password) {
|
||||
params.password = values.password;
|
||||
}
|
||||
|
||||
const success = await updateDomainById(params);
|
||||
setSubmitting(false);
|
||||
if (success) {
|
||||
message.success(t("domainUpdated"));
|
||||
onClose();
|
||||
} else {
|
||||
message.error(t("updateFailed"));
|
||||
showError("unknown", t("updateFailed"));
|
||||
}
|
||||
} 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 = {
|
||||
name,
|
||||
domain: processedDomain,
|
||||
@@ -106,55 +274,37 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
|
||||
};
|
||||
|
||||
const success = await createDomain(params);
|
||||
setSubmitting(false);
|
||||
if (success) {
|
||||
message.success(t("domainCreated"));
|
||||
onClose();
|
||||
} else {
|
||||
message.error(t("createFailed"));
|
||||
showError("unknown", t("createFailed"));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitting(false);
|
||||
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
|
||||
const handleTestConnection = async () => {
|
||||
try {
|
||||
const values = await form.validateFields([
|
||||
"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 (
|
||||
<Button onClick={handleTestConnection} loading={testing} icon={getIcon()}>
|
||||
{t("testConnection")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -167,7 +317,7 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
|
||||
destroyOnHidden
|
||||
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 }}>
|
||||
<Input placeholder={t("nameOptional")} />
|
||||
</Form.Item>
|
||||
@@ -180,17 +330,8 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
|
||||
{
|
||||
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)) {
|
||||
const processed = processDomain(value);
|
||||
if (validateDomainFormat(processed)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(t("validDomainRequired")));
|
||||
@@ -212,21 +353,42 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
|
||||
<Form.Item
|
||||
name="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")}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginTop: 24, marginBottom: 0 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
|
||||
<Button onClick={handleTestConnection} loading={testing}>
|
||||
{t("testConnection")}
|
||||
</Button>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Form.Item style={{ marginTop: 32, marginBottom: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
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 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")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
/**
|
||||
* 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 { useTranslation } from "react-i18next";
|
||||
import { Tag, Popconfirm, Space } from "antd";
|
||||
import { Button, Tooltip } from "@lobehub/ui";
|
||||
import { Avatar } from "@lobehub/ui";
|
||||
import {
|
||||
Cloud,
|
||||
Pencil,
|
||||
Trash2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
HelpCircle,
|
||||
GripVertical,
|
||||
} from "lucide-react";
|
||||
import { Popconfirm, Space } from "antd";
|
||||
import { Button, SortableList, Tooltip } from "@lobehub/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { createStyles } from "antd-style";
|
||||
import { useDomainStore, useUIStore } from "@renderer/stores";
|
||||
import { useDomainStore } from "@renderer/stores";
|
||||
import type { Domain } from "@shared/types/domain";
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
item: css`
|
||||
padding: ${token.paddingSM}px;
|
||||
itemWrapper: css`
|
||||
width: 100%;
|
||||
padding: ${token.paddingXS}px ${token.paddingSM}px;
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
background: ${token.colorBgContainer};
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
margin-bottom: ${token.marginXS}px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -35,15 +25,17 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
border-color: ${token.colorPrimary};
|
||||
box-shadow: ${token.boxShadowSecondary};
|
||||
}
|
||||
|
||||
&:hover .domain-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
`,
|
||||
|
||||
selectedItem: css`
|
||||
border-color: ${token.colorPrimary};
|
||||
background: ${token.colorPrimaryBg};
|
||||
`,
|
||||
itemDragging: css`
|
||||
opacity: 0.5;
|
||||
border: 2px dashed ${token.colorPrimary};
|
||||
`,
|
||||
|
||||
domainInfo: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -51,6 +43,7 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`,
|
||||
|
||||
domainName: css`
|
||||
font-weight: ${token.fontWeightStrong};
|
||||
font-size: ${token.fontSizeLG}px;
|
||||
@@ -58,6 +51,7 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
|
||||
domainUrl: css`
|
||||
color: ${token.colorTextSecondary};
|
||||
font-size: ${token.fontSizeSM}px;
|
||||
@@ -65,33 +59,29 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
|
||||
actions: css`
|
||||
position: absolute;
|
||||
right: ${token.paddingXS}px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
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`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: ${token.paddingSM}px;
|
||||
gap: ${token.paddingXS}px;
|
||||
position: relative;
|
||||
`,
|
||||
|
||||
domainText: css`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -106,17 +96,8 @@ interface DomainListProps {
|
||||
const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
|
||||
const { t } = useTranslation("domain");
|
||||
const { styles } = useStyles();
|
||||
const {
|
||||
domains,
|
||||
currentDomain,
|
||||
connectionStatuses,
|
||||
switchDomain,
|
||||
deleteDomain,
|
||||
testConnection,
|
||||
reorderDomains,
|
||||
} = useDomainStore();
|
||||
const { domainIconColors } = useUIStore();
|
||||
const [draggingIndex, setDraggingIndex] = React.useState<number | null>(null);
|
||||
const { domains, currentDomain, switchDomain, deleteDomain, reorderDomains } =
|
||||
useDomainStore();
|
||||
|
||||
const handleSelect = (domain: Domain) => {
|
||||
switchDomain(domain);
|
||||
@@ -126,109 +107,46 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
|
||||
await deleteDomain(id);
|
||||
};
|
||||
|
||||
const getStatusIcon = (id: string) => {
|
||||
const status = connectionStatuses[id];
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return <CheckCircle size={16} style={{ color: "#52c41a" }} />;
|
||||
case "error":
|
||||
return <XCircle size={16} style={{ color: "#ff4d4f" }} />;
|
||||
default:
|
||||
return <HelpCircle size={16} style={{ color: "#faad14" }} />;
|
||||
// Handle reorder - convert SortableListItem[] back to reorder action
|
||||
const handleSortChange = (newItems: { id: string }[]) => {
|
||||
const newOrder = newItems.map((item) => item.id);
|
||||
const oldOrder = domains.map((d) => d.id);
|
||||
|
||||
// Find the from and to indices
|
||||
for (let i = 0; i < oldOrder.length; i++) {
|
||||
if (oldOrder[i] !== newOrder[i]) {
|
||||
const fromIndex = i;
|
||||
const toIndex = newOrder.indexOf(oldOrder[i]);
|
||||
reorderDomains(fromIndex, toIndex);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTag = (id: string) => {
|
||||
const status = connectionStatuses[id];
|
||||
switch (status) {
|
||||
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 renderItem = (item: { id: string }) => {
|
||||
const domain = domains.find((d) => d.id === item.id);
|
||||
if (!domain) return null;
|
||||
|
||||
const getDomainIconColor = (domainId: string, isSelected: boolean) => {
|
||||
if (domainIconColors[domainId]) {
|
||||
return domainIconColors[domainId];
|
||||
}
|
||||
return isSelected ? "#1890ff" : "#87d068";
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = (index: number) => {
|
||||
setDraggingIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggingIndex === null || draggingIndex === index) return;
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggingIndex === null || draggingIndex === index) return;
|
||||
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 (
|
||||
<SortableList.Item id={domain.id}>
|
||||
<div
|
||||
key={domain.id}
|
||||
className={`${styles.item} ${isSelected ? styles.selectedItem : ""} ${isDragging ? styles.itemDragging : ""}`}
|
||||
className={`${styles.itemWrapper} ${isSelected ? styles.selectedItem : ""}`}
|
||||
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>
|
||||
<SortableList.DragHandle />
|
||||
<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.domainName}>{domain.name}</div>
|
||||
<div className={styles.domainUrl}>
|
||||
{domain.domain} · {domain.username}
|
||||
{domain.username} · {domain.domain}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Tooltip title={t("testConnection")}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={getStatusIcon(domain.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
testConnection(domain.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Space className={"domain-item-actions " + styles.actions}>
|
||||
<Tooltip title={t("edit")}>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -251,6 +169,7 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
|
||||
okText={t("delete", { ns: "common" })}
|
||||
cancelText={t("cancel", { ns: "common" })}
|
||||
>
|
||||
<Tooltip title={t("delete", { ns: "common" })}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
@@ -258,13 +177,22 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
|
||||
icon={<Trash2 size={16} />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</SortableList.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
};
|
||||
|
||||
return (
|
||||
<SortableList
|
||||
items={domains}
|
||||
renderItem={renderItem}
|
||||
onChange={handleSortChange}
|
||||
gap={4}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -38,10 +38,9 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: ${token.marginLG}px;
|
||||
margin-bottom: ${token.marginXXS}px;
|
||||
padding: 0 ${token.paddingLG}px;
|
||||
padding-top: ${token.paddingLG}px;
|
||||
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||
padding-top: ${token.paddingXS}px;
|
||||
`,
|
||||
title: css`
|
||||
font-size: ${token.fontSizeHeading4}px;
|
||||
@@ -56,7 +55,7 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
content: css`
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0 ${token.paddingLG}px;
|
||||
padding: 0 ${token.paddingSM}px;
|
||||
`,
|
||||
loading: css`
|
||||
display: flex;
|
||||
@@ -194,7 +193,7 @@ const DomainManager: React.FC<DomainManagerProps> = ({
|
||||
{currentDomain.name}
|
||||
</div>
|
||||
<div className={styles.collapsedUrl}>
|
||||
{currentDomain.domain}
|
||||
{currentDomain.username} · {currentDomain.domain}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -256,11 +255,7 @@ const DomainManager: React.FC<DomainManagerProps> = ({
|
||||
) : domains.length === 0 ? (
|
||||
<Empty
|
||||
description={t("noDomainConfig")}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Button type="primary" onClick={handleAdd}>
|
||||
{t("addFirstDomainConfig")}
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<DomainList onEdit={handleEdit} />
|
||||
|
||||
@@ -194,7 +194,6 @@ const VersionHistory: React.FC = () => {
|
||||
<div style={{ padding: 24, textAlign: "center" }}>
|
||||
<Empty
|
||||
description={t("selectApp", { ns: "app" })}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,7 +229,6 @@ const VersionHistory: React.FC = () => {
|
||||
{versions.length === 0 ? (
|
||||
<Empty
|
||||
description={t("noVersions")}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
"domainManagement": "Domain Management",
|
||||
"add": "Add",
|
||||
"noDomainConfig": "No Domain configuration",
|
||||
"addFirstDomainConfig": "Add first Domain",
|
||||
"noDomainSelected": "No Domain selected",
|
||||
"edit": "Edit",
|
||||
"testConnection": "Test Connection",
|
||||
@@ -55,6 +54,8 @@
|
||||
"update": "Update",
|
||||
"domainCreated": "Domain created successfully",
|
||||
"domainUpdated": "Domain updated successfully",
|
||||
"createConnectionFailed": "Connection failed during creation",
|
||||
"domainDuplicate": "This domain and username combination already exists",
|
||||
"createFailed": "Creation failed",
|
||||
"updateFailed": "Update failed"
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
"domainManagement": "ドメイン管理",
|
||||
"add": "追加",
|
||||
"noDomainConfig": "ドメイン設定がありません",
|
||||
"addFirstDomainConfig": "最初のドメインを追加",
|
||||
"noDomainSelected": "ドメイン未選択",
|
||||
"edit": "編集",
|
||||
"testConnection": "接続テスト",
|
||||
@@ -55,6 +54,8 @@
|
||||
"update": "更新",
|
||||
"domainCreated": "ドメインを作成しました",
|
||||
"domainUpdated": "ドメインを更新しました",
|
||||
"createConnectionFailed": "接続に失敗しました",
|
||||
"domainDuplicate": "このドメインとユーザー名の組み合わせは既に存在します",
|
||||
"createFailed": "作成に失敗しました",
|
||||
"updateFailed": "更新に失敗しました"
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
"domainManagement": "Domain 管理",
|
||||
"add": "添加",
|
||||
"noDomainConfig": "暂无 Domain 配置",
|
||||
"addFirstDomainConfig": "添加第一个 Domain",
|
||||
"noDomainSelected": "未选择 Domain",
|
||||
"edit": "编辑",
|
||||
"testConnection": "测试连接",
|
||||
@@ -55,6 +54,8 @@
|
||||
"update": "更新",
|
||||
"domainCreated": "Domain 创建成功",
|
||||
"domainUpdated": "Domain 更新成功",
|
||||
"createConnectionFailed": "创建时连接失败",
|
||||
"domainDuplicate": "该域名和用户名组合已存在",
|
||||
"createFailed": "创建失败",
|
||||
"updateFailed": "更新失败"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user