diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 7ef9279..9c8760c 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -295,12 +295,14 @@ interface Domain { - 显示 App 名称、App ID、所属 Space ID(若有)、创建时间 - App 数据持久化存储,下次打开应用时自动加载 - 支持重新加载(覆盖已有数据) - -**FR-BROWSE-002**: 分页显示 -- App 列表支持分页,默认每页 20 条 -- 显示总数量和当前页码 -- 支持切换页码 - +HW| +BB|**FR-BROWSE-002**: 列表显示 +- App 列表使用可点击列表(List 组件)展示,无需分页 +- 全量显示所有 App,显示 App 名称和 App ID +- 点击列表项即可选择 App +- 显示总数量和加载时间 +- 所属 Space 暂时不显示(后续版本支持) +NK| **FR-BROWSE-003**: 搜索过滤 - 支持按 App 名称搜索 - 搜索结果实时过滤 diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 42f0885..656e3ee 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -106,11 +106,26 @@ function registerGetDomains(): void { /** * Create a new domain + * Deduplication: Check if domain+username already exists */ function registerCreateDomain(): void { handleWithParams( "createDomain", async (params) => { + // Check for duplicate domain+username + const existingDomains = await listDomains(); + const duplicate = existingDomains.find( + (d) => + d.domain.toLowerCase() === params.domain.toLowerCase() && + d.username.toLowerCase() === params.username.toLowerCase() + ); + + if (duplicate) { + throw new Error( + `Domain "${params.domain}" with user "${params.username}" already exists. Please edit the existing domain instead.` + ); + } + const now = new Date().toISOString(); const domain: Domain = { id: uuidv4(), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9d3857a..4f68775 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -10,18 +10,19 @@ import { theme, ConfigProvider, App as AntApp, - Tabs, Button, Space, Dropdown, + Tooltip, } from "antd"; import { SettingOutlined, GithubOutlined, CloudServerOutlined, - AppstoreOutlined, CloudUploadOutlined, HistoryOutlined, + LeftOutlined, + RightOutlined, } from "@ant-design/icons"; import { createStyles } from "antd-style"; import zhCN from "antd/locale/zh_CN"; @@ -32,11 +33,14 @@ import { AppDetail } from "@renderer/components/AppDetail"; import { DeployDialog } from "@renderer/components/DeployDialog"; const { Header, Content, Sider } = Layout; -const { Title, Text } = Typography; +const { Title } = Typography; // Domain section heights -const DOMAIN_SECTION_COLLAPSED = 56; // Just show current domain -const DOMAIN_SECTION_EXPANDED = 240; // Show full list +const DOMAIN_SECTION_COLLAPSED = 56; +const DOMAIN_SECTION_EXPANDED = 240; +const DEFAULT_SIDER_WIDTH = 320; +const MIN_SIDER_WIDTH = 280; +const MAX_SIDER_WIDTH = 500; const useStyles = createStyles(({ token, css }) => ({ layout: css` @@ -52,12 +56,17 @@ const useStyles = createStyles(({ token, css }) => ({ background: ${token.colorBgContainer}; border-right: 1px solid ${token.colorBorderSecondary}; `, + siderCollapsed: css` + width: 0 !important; + min-width: 0 !important; + overflow: hidden; + `, logo: css` height: 48px; margin: 8px 16px; display: flex; align-items: center; - justify-content: center; + justify-content: space-between; gap: 8px; border-bottom: 1px solid ${token.colorBorderSecondary}; `, @@ -72,7 +81,11 @@ const useStyles = createStyles(({ token, css }) => ({ flex-direction: column; `, mainLayout: css` - margin-left: 280px; + margin-left: ${DEFAULT_SIDER_WIDTH}px; + transition: margin-left 0.2s; + `, + mainLayoutCollapsed: css` + margin-left: 0; transition: margin-left 0.2s; `, header: css` @@ -94,12 +107,6 @@ const useStyles = createStyles(({ token, css }) => ({ display: flex; height: 100%; `, - leftPanel: css` - width: 300px; - border-right: 1px solid ${token.colorBorderSecondary}; - height: 100%; - overflow: hidden; - `, rightPanel: css` flex: 1; height: 100%; @@ -114,6 +121,28 @@ const useStyles = createStyles(({ token, css }) => ({ overflow: hidden; transition: height 0.2s ease-in-out; `, + resizeHandle: css` + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 4px; + cursor: col-resize; + background: transparent; + transition: background 0.2s; + z-index: 10; + + &:hover { + background: ${token.colorPrimary}; + } + `, + collapseButton: css` + position: fixed; + top: 50%; + transform: translateY(-50%); + z-index: 1000; + transition: left 0.2s; + `, })); const App: React.FC = () => { @@ -125,42 +154,122 @@ const App: React.FC = () => { const { currentDomain } = useDomainStore(); const [deployDialogOpen, setDeployDialogOpen] = React.useState(false); const [domainExpanded, setDomainExpanded] = React.useState(true); + const [siderCollapsed, setSiderCollapsed] = React.useState(false); + const [siderWidth, setSiderWidth] = React.useState(DEFAULT_SIDER_WIDTH); + const [isResizing, setIsResizing] = React.useState(false); const domainSectionHeight = domainExpanded ? DOMAIN_SECTION_EXPANDED : DOMAIN_SECTION_COLLAPSED; + // Handle resize start + const handleResizeStart = React.useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + + const startX = e.clientX; + const startWidth = siderWidth; + + const handleMouseMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientX - startX; + const newWidth = Math.min( + MAX_SIDER_WIDTH, + Math.max(MIN_SIDER_WIDTH, startWidth + delta), + ); + setSiderWidth(newWidth); + }; + + const handleMouseUp = () => { + setIsResizing(false); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }, + [siderWidth], + ); + + const toggleSider = () => { + setSiderCollapsed(!siderCollapsed); + }; + return ( {/* Left Sider - Domain List & App List */} - -
- - Kintone Manager -
-
-
- setDomainExpanded(!domainExpanded)} + + {!siderCollapsed && ( + <> +
+
+ + Kintone Manager +
+
+
+
+ + setDomainExpanded(!domainExpanded) + } + /> +
+
+ +
+
+ {/* Resize handle */} +
-
-
- -
-
+ + )} + {/* Collapse/Expand Button */} +
+ +
+ {/* Main Content */} - +
{currentDomain diff --git a/src/renderer/src/components/AppList/AppList.tsx b/src/renderer/src/components/AppList/AppList.tsx index 4aab359..29e450e 100644 --- a/src/renderer/src/components/AppList/AppList.tsx +++ b/src/renderer/src/components/AppList/AppList.tsx @@ -1,29 +1,16 @@ /** * AppList Component - * Displays apps in a table with pagination, search, and load functionality + * Displays apps in a clickable list (no pagination) */ import React from "react"; -import { - Table, - Button, - Input, - Empty, - Spin, - Typography, - Tag, - Space, - Tooltip, - Pagination, -} from "antd"; +import { List, Button, Input, Empty, Spin, Typography, Space } from "antd"; import { ReloadOutlined, SearchOutlined, AppstoreOutlined, - ClockCircleOutlined, } from "@ant-design/icons"; import { createStyles } from "antd-style"; -import type { ColumnsType } from "antd/es/table"; import { useAppStore } from "@renderer/stores"; import { useDomainStore } from "@renderer/stores"; import type { KintoneApp } from "@shared/types/kintone"; @@ -44,6 +31,7 @@ const useStyles = createStyles(({ token, css }) => ({ justify-content: space-between; align-items: center; gap: ${token.paddingSM}px; + flex-shrink: 0; `, searchWrapper: css` flex: 1; @@ -52,14 +40,6 @@ const useStyles = createStyles(({ token, css }) => ({ content: css` flex: 1; overflow: auto; - padding: ${token.paddingSM}px; - `, - footer: css` - padding: ${token.paddingSM}px ${token.paddingMD}px; - border-top: 1px solid ${token.colorBorderSecondary}; - display: flex; - justify-content: space-between; - align-items: center; `, loading: css` display: flex; @@ -74,17 +54,52 @@ const useStyles = createStyles(({ token, css }) => ({ align-items: center; height: 300px; `, - spaceId: css` + listItem: css` + cursor: pointer; + transition: background 0.2s; + padding: ${token.paddingSM}px ${token.paddingMD}px !important; + border-bottom: 1px solid ${token.colorBorderSecondary} !important; + + &:hover { + background: ${token.colorBgTextHover}; + } + `, + listItemActive: css` + background: ${token.colorPrimaryBgHover} !important; + border-left: 3px solid ${token.colorPrimary} !important; + `, + appInfo: css` + display: flex; + align-items: center; + gap: ${token.paddingSM}px; + flex: 1; + min-width: 0; + `, + appName: css` + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + `, + appId: css` font-family: monospace; font-size: 12px; + color: ${token.colorTextSecondary}; + flex-shrink: 0; + `, + footer: css` + padding: ${token.paddingSM}px ${token.paddingMD}px; + border-top: 1px solid ${token.colorBorderSecondary}; + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; `, loadedInfo: css` color: ${token.colorTextSecondary}; font-size: 12px; `, - clockIcon: css` - color: ${token.colorTextSecondary}; - `, })); const AppList: React.FC = () => { @@ -94,18 +109,14 @@ const AppList: React.FC = () => { apps, loading, error, - currentPage, - pageSize, searchText, loadedAt, + selectedAppId, setApps, setLoading, setError, - setCurrentPage, - setPageSize, setSearchText, setSelectedAppId, - setCurrentApp, } = useAppStore(); // Load apps from Kintone @@ -126,7 +137,6 @@ const AppList: React.FC = () => { (a, b) => parseInt(b.appId, 10) - parseInt(a.appId, 10), ); setApps(sortedApps); - setCurrentPage(1); } else { setError(result.error || "加载应用失败"); } @@ -145,80 +155,34 @@ const AppList: React.FC = () => { (app) => app.name.toLowerCase().includes(lowerSearch) || app.appId.includes(searchText) || - (app.code && app.code.toLowerCase().includes(lowerSearch)) || - (app.spaceId && app.spaceId.includes(searchText)), + (app.code && app.code.toLowerCase().includes(lowerSearch)), ); }, [apps, searchText]); - // Paginated apps - const paginatedApps = React.useMemo(() => { - const start = (currentPage - 1) * pageSize; - const end = start + pageSize; - return filteredApps.slice(start, end); - }, [filteredApps, currentPage, pageSize]); - - // Handle row click - const handleRowClick = (record: KintoneApp) => { - setSelectedAppId(record.appId); - // The AppDetail component will fetch the full app details + // Handle item click + const handleItemClick = (app: KintoneApp) => { + setSelectedAppId(app.appId); }; - // Table columns - const columns: ColumnsType<KintoneApp> = [ - { - title: "App ID", - dataIndex: "appId", - key: "appId", - width: 100, - render: (appId: string) => ( - <Text code style={{ fontSize: 12 }}> - {appId} - </Text> - ), - }, - { - title: "应用名称", - dataIndex: "name", - key: "name", - ellipsis: true, - render: (name: string) => ( - <Space> - <AppstoreOutlined style={{ color: "#1890ff" }} /> - <Text strong>{name}</Text> - </Space> - ), - }, - { - title: "所属 Space", - dataIndex: "spaceId", - key: "spaceId", - width: 120, - render: (spaceId?: string) => - spaceId ? ( - <Tooltip title="Space ID"> - <Tag color="blue" className={styles.spaceId}> - {spaceId} - </Tag> - </Tooltip> - ) : ( - <Text type="secondary">-</Text> - ), - }, - { - title: "创建时间", - dataIndex: "createdAt", - key: "createdAt", - width: 180, - render: (createdAt: string) => ( - <Space size={4}> - <ClockCircleOutlined className={styles.clockIcon} /> - <Text type="secondary" style={{ fontSize: 12 }}> - {new Date(createdAt).toLocaleString("zh-CN")} + // Render list item + const renderItem = (app: KintoneApp) => { + const isActive = selectedAppId === app.appId; + + return ( + <List.Item + className={`${styles.listItem} ${isActive ? styles.listItemActive : ""}`} + onClick={() => handleItemClick(app)} + > + <div className={styles.appInfo}> + <AppstoreOutlined style={{ color: "#1890ff", fontSize: 16 }} /> + <span className={styles.appName}>{app.name}</span> + <Text code className={styles.appId}> + ID: {app.appId} </Text> - </Space> - ), - }, - ]; + </div> + </List.Item> + ); + }; if (!currentDomain) { return ( @@ -270,21 +234,15 @@ const AppList: React.FC = () => { </Empty> </div> ) : ( - <Table - columns={columns} - dataSource={paginatedApps} - rowKey="appId" - pagination={false} - size="small" - onRow={(record) => ({ - onClick: () => handleRowClick(record), - style: { cursor: "pointer" }, - })} + <List + dataSource={filteredApps} + renderItem={renderItem} + locale={{ emptyText: "没有匹配的应用" }} /> )} </div> - {/* Footer with pagination */} + {/* Footer with info */} {apps.length > 0 && ( <div className={styles.footer}> <div className={styles.loadedInfo}> @@ -297,24 +255,6 @@ const AppList: React.FC = () => { 共 {filteredApps.length} 个应用 </Text> </div> - <Pagination - current={currentPage} - pageSize={pageSize} - total={filteredApps.length} - onChange={(page, size) => { - setCurrentPage(page); - if (size !== pageSize) { - setPageSize(size); - } - }} - showSizeChanger - showQuickJumper - size="small" - pageSizeOptions={["20", "50", "100"]} - showTotal={(total) => - `第 ${(currentPage - 1) * pageSize + 1}-${Math.min(currentPage * pageSize, total)} 条` - } - /> </div> )} </div> diff --git a/src/renderer/src/components/CodeViewer/CodeViewer.tsx b/src/renderer/src/components/CodeViewer/CodeViewer.tsx index 0dac816..d3c45bf 100644 --- a/src/renderer/src/components/CodeViewer/CodeViewer.tsx +++ b/src/renderer/src/components/CodeViewer/CodeViewer.tsx @@ -91,8 +91,16 @@ const CodeViewer: React.FC<CodeViewerProps> = ({ }); if (result.success) { - // Decode base64 content - const decoded = atob(result.data.content || ""); + // Decode base64 content properly for UTF-8 (including Japanese characters) + const base64 = result.data.content || ""; + const binaryString = atob(base64); + // Decode as UTF-8 to properly handle Japanese and other multi-byte characters + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const decoder = new TextDecoder("utf-8"); + const decoded = decoder.decode(bytes); setContent(decoded); // Detect language from file name diff --git a/src/renderer/src/components/DomainManager/DomainManager.tsx b/src/renderer/src/components/DomainManager/DomainManager.tsx index 01b5727..5030f5e 100644 --- a/src/renderer/src/components/DomainManager/DomainManager.tsx +++ b/src/renderer/src/components/DomainManager/DomainManager.tsx @@ -2,6 +2,7 @@ * DomainManager Component * Main container for domain management * Supports collapsed/expanded view + * Expand/collapse triggered by clicking bottom area */ import React from "react"; @@ -23,7 +24,6 @@ const useStyles = createStyles(({ token, css }) => ({ height: 100%; display: flex; flex-direction: column; - padding: ${token.paddingLG}px; background: ${token.colorBgContainer}; `, collapsedContainer: css` @@ -37,7 +37,8 @@ const useStyles = createStyles(({ token, css }) => ({ justify-content: space-between; align-items: center; margin-bottom: ${token.marginLG}px; - padding-bottom: ${token.paddingMD}px; + padding: 0 ${token.paddingLG}px; + padding-top: ${token.paddingLG}px; border-bottom: 1px solid ${token.colorBorderSecondary}; `, title: css` @@ -53,6 +54,7 @@ const useStyles = createStyles(({ token, css }) => ({ content: css` flex: 1; overflow: auto; + padding: 0 ${token.paddingLG}px; `, loading: css` display: flex; @@ -102,6 +104,24 @@ const useStyles = createStyles(({ token, css }) => ({ font-size: ${token.fontSizeSM}px; padding: ${token.paddingSM}px ${token.paddingMD}px; `, + // Bottom toggle area - click to expand/collapse + bottomToggleArea: css` + display: flex; + align-items: center; + justify-content: center; + padding: ${token.paddingSM}px; + cursor: pointer; + border-top: 1px solid ${token.colorBorderSecondary}; + transition: background 0.2s; + color: ${token.colorTextSecondary}; + font-size: 12px; + gap: ${token.paddingXS}px; + + &:hover { + background: ${token.colorBgTextHover}; + color: ${token.colorPrimary}; + } + `, })); interface DomainManagerProps { @@ -140,12 +160,11 @@ const DomainManager: React.FC<DomainManagerProps> = ({ const handleRefresh = () => { loadDomains(); }; - // Collapsed view - show current domain only if (collapsed) { return ( <div className={styles.collapsedContainer}> - <div className={styles.collapsedHeader} onClick={onToggleCollapse}> + <div className={styles.collapsedHeader}> <div className={styles.collapsedInfo}> <Avatar icon={<CloudServerOutlined />} @@ -169,26 +188,23 @@ const DomainManager: React.FC<DomainManagerProps> = ({ )} </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> + <Tooltip title="添加 Domain"> + <Button + type="text" + size="small" + icon={<PlusOutlined />} + onClick={(e) => { + e.stopPropagation(); + handleAdd(); + }} + /> + </Tooltip> + </div> + + {/* Bottom toggle area - click to expand */} + <div className={styles.bottomToggleArea} onClick={onToggleCollapse}> + <DownOutlined /> + <span>点击展开</span> </div> <DomainForm @@ -206,9 +222,6 @@ const DomainManager: React.FC<DomainManagerProps> = ({ <div className={styles.header}> <h2 className={styles.title}>Domain 管理</h2> <div className={styles.actions}> - <Tooltip title="收起"> - <Button icon={<UpOutlined />} onClick={onToggleCollapse} /> - </Tooltip> <Tooltip title="刷新"> <Button icon={<ReloadOutlined />} @@ -241,6 +254,12 @@ const DomainManager: React.FC<DomainManagerProps> = ({ )} </div> + {/* Bottom toggle area - click to collapse */} + <div className={styles.bottomToggleArea} onClick={onToggleCollapse}> + <UpOutlined /> + <span>点击收起</span> + </div> + <DomainForm open={formOpen} onClose={handleCloseForm}