From 1543e09eeb334b30e4c62e742236bc45fba7e046 Mon Sep 17 00:00:00 2001 From: "z.zhang" Date: Fri, 9 Oct 2026 14:00:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20=E5=B7=A5=E7=A8=8B=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E9=80=89=E6=8B=A9=E5=8F=AF=E8=A7=81=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E2=80=94=E2=80=94=E5=86=85=E5=B5=8C=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=B5=8F=E8=A7=88=E5=99=A8=E5=8F=96=E4=BB=A3=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E7=BA=AF=E6=96=87=E4=BB=B6=E5=A4=B9=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原生 dialog.showOpenDialog(openDirectory) 只列文件夹,用户无法确认 目录里的 Excel/PDF/SQL 等排产数据文件。新增 aps:list-directory IPC (只读列举、盘符根目录、条目上限、不可读显式报错),Web 端新建项目 「浏览…」改开内嵌浏览器:文件夹可下钻、文件按类型着色展示(Excel/ PDF/SQL/DXF)并带大小/日期,底部汇总本目录业务文件计数。旧版桌面桥 回退原生选择框。tsc + vite build + 桌面 Node 测试 48 全过。 --- apps/desktop/main.cjs | 69 ++++++++++ apps/desktop/preload.cjs | 3 + apps/web/src/aps-desktop.d.ts | 8 ++ apps/web/src/shell/AppDialog.tsx | 14 +++ apps/web/src/shell/FolderBrowser.tsx | 181 +++++++++++++++++++++++++++ apps/web/src/styles.css | 31 +++++ 6 files changed, 306 insertions(+) create mode 100644 apps/web/src/shell/FolderBrowser.tsx diff --git a/apps/desktop/main.cjs b/apps/desktop/main.cjs index 831e84d..92843ce 100644 --- a/apps/desktop/main.cjs +++ b/apps/desktop/main.cjs @@ -604,6 +604,75 @@ ipcMain.handle('aps:pick-directory', async (event) => { return result.filePaths[0]; }); +// 工程目录内嵌浏览器(round 修复:原生文件夹选择框只显示文件夹, +// 用户看不到目录里的 Excel/PDF/SQL 等业务文件)。渲染进程按需列目录: +// 传 path 列该目录;path 为空 → Windows 列盘符、其他平台列根目录。 +// 只读列举,绝不写文件系统;目录不可读显式返回 error(fail closed)。 +const LIST_DIRECTORY_MAX_ENTRIES = 2000; + +function listWindowsDrives() { + const drives = []; + for (let code = 67; code <= 90; code += 1) { // C..Z + const root = `${String.fromCharCode(code)}:\\`; + try { + fs.accessSync(root, fs.constants.R_OK); + drives.push(root); + } catch { /* 盘符不存在或不可读,跳过 */ } + } + return drives; +} + +ipcMain.handle('aps:list-directory', async (event, rawPath) => { + if (!isTrustedIpcEvent(event)) throw new Error('Untrusted IPC sender'); + const target = typeof rawPath === 'string' ? rawPath.trim() : ''; + if (!target) { + if (process.platform === 'win32') { + return { + path: '', parent: null, + entries: listWindowsDrives().map((root) => ({ + name: root, path: root, type: 'dir', size: null, mtime: null, + })), + }; + } + return listDirectoryEntries(path.parse(os.homedir()).root); + } + return listDirectoryEntries(path.resolve(target)); +}); + +function listDirectoryEntries(dirPath) { + let items; + try { + items = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch (err) { + return { path: dirPath, parent: path.dirname(dirPath), entries: [], error: `目录不可读:${err.message}` }; + } + const entries = []; + for (const item of items.slice(0, LIST_DIRECTORY_MAX_ENTRIES)) { + const full = path.join(dirPath, item.name); + if (item.isDirectory()) { + entries.push({ name: item.name, path: full, type: 'dir', size: null, mtime: null }); + } else if (item.isFile()) { + let stat = null; + try { stat = fs.statSync(full); } catch { /* 读取失败按 0 展示 */ } + entries.push({ + name: item.name, path: full, type: 'file', + size: stat ? stat.size : null, + mtime: stat ? stat.mtimeMs : null, + }); + } + } + entries.sort((a, b) => (a.type === b.type + ? a.name.localeCompare(b.name, 'zh-Hans-CN', { sensitivity: 'base' }) + : a.type === 'dir' ? -1 : 1)); // 目录在前、文件在后 + const parent = path.dirname(dirPath); + return { + path: dirPath, + parent: parent === dirPath ? null : parent, + entries, + truncated: items.length > LIST_DIRECTORY_MAX_ENTRIES, + }; +} + ipcMain.on('aps:toggle-devtools', (event) => { if (!isTrustedIpcEvent(event)) return; const wc = event.sender; diff --git a/apps/desktop/preload.cjs b/apps/desktop/preload.cjs index 8f71a1e..996c834 100644 --- a/apps/desktop/preload.cjs +++ b/apps/desktop/preload.cjs @@ -34,6 +34,9 @@ try { async pickDirectory() { return ipcRenderer.invoke('aps:pick-directory'); }, + async listDirectory(dirPath) { + return ipcRenderer.invoke('aps:list-directory', dirPath || ''); + }, /** 离线升级入口(updater.cjs):检查更新 / 应用升级(失败自动回滚)/ 手动回滚 */ checkForUpdate(options) { diff --git a/apps/web/src/aps-desktop.d.ts b/apps/web/src/aps-desktop.d.ts index 5f230d6..4d2fef7 100644 --- a/apps/web/src/aps-desktop.d.ts +++ b/apps/web/src/aps-desktop.d.ts @@ -11,6 +11,14 @@ export interface ApsDesktopBridge { openSkills?: () => void; openSessions?: () => void; pickDirectory?: () => Promise; + /** 内嵌工程目录浏览器:列目录内容(目录 + 文件),path 为空时列盘符/根目录 */ + listDirectory?: (dirPath?: string) => Promise<{ + path: string; + parent: string | null; + entries: { name: string; path: string; type: 'dir' | 'file'; size: number | null; mtime: number | null }[]; + truncated?: boolean; + error?: string; + }>; toggleDevTools?: () => void; toggleFullScreen?: () => void; leaveFullScreen?: () => void; diff --git a/apps/web/src/shell/AppDialog.tsx b/apps/web/src/shell/AppDialog.tsx index 9a355f8..eb8a396 100644 --- a/apps/web/src/shell/AppDialog.tsx +++ b/apps/web/src/shell/AppDialog.tsx @@ -3,6 +3,7 @@ // 替代 window.prompt / confirm:遮罩 + 卡片,风格对齐设计系统 // ============================================================ import { useEffect, useRef, useState } from 'react'; +import { FolderBrowserDialog } from './FolderBrowser'; /** 输入名对话框(新建对话/文件/重命名) */ export function NameDialog(props: { @@ -71,6 +72,7 @@ export function ProjectCreateDialog(props: { }) { const [name, setName] = useState('新工厂排产项目'); const [workDir, setWorkDir] = useState(''); + const [browserOpen, setBrowserOpen] = useState(false); const inputRef = useRef(null); const submittingRef = useRef(false); const [submitting, setSubmitting] = useState(false); @@ -82,6 +84,7 @@ export function ProjectCreateDialog(props: { setName('新工厂排产项目'); setWorkDir(''); setError(''); + setBrowserOpen(false); requestAnimationFrame(() => { inputRef.current?.focus(); inputRef.current?.select(); @@ -91,6 +94,11 @@ export function ProjectCreateDialog(props: { if (!props.open) return null; const pickDir = async () => { + // 优先内嵌浏览器(可见 Excel/PDF/SQL 等文件);旧版桌面桥回退原生选择框 + if (window.apsDesktop?.listDirectory) { + setBrowserOpen(true); + return; + } const dir = await window.apsDesktop?.pickDirectory?.(); if (dir) setWorkDir(dir); }; @@ -158,6 +166,12 @@ export function ProjectCreateDialog(props: { {submitting ? '正在准备项目…' : error ? '重试' : '创建'} + setBrowserOpen(false)} + onSelect={(dir) => { setWorkDir(dir); setBrowserOpen(false); }} + /> ); diff --git a/apps/web/src/shell/FolderBrowser.tsx b/apps/web/src/shell/FolderBrowser.tsx new file mode 100644 index 0000000..3117494 --- /dev/null +++ b/apps/web/src/shell/FolderBrowser.tsx @@ -0,0 +1,181 @@ +// ============================================================ +// 内嵌工程目录浏览器(moduleId: web-folder-browser) +// 修复:原生文件夹选择框只显示文件夹,目录里的 Excel/PDF/SQL 等 +// 业务文件不可见。本组件通过 apsDesktop.listDirectory 把目录内容 +// (文件夹 + 文件)完整列出,文件夹可下钻,文件只读展示, +// 「选择此文件夹」返回当前路径。桌面桥不存在时不渲染入口。 +// ============================================================ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +type DirEntry = { + name: string; + path: string; + type: 'dir' | 'file'; + size: number | null; + mtime: number | null; +}; + +type DirListing = { + path: string; + parent: string | null; + entries: DirEntry[]; + truncated?: boolean; + error?: string; +}; + +/** 业务文件类型着色(排产常见格式) */ +const EXT_STYLE: Record = { + xlsx: { label: 'Excel', cls: 'fb-ext-excel' }, + xlsm: { label: 'Excel', cls: 'fb-ext-excel' }, + xls: { label: 'Excel', cls: 'fb-ext-excel' }, + csv: { label: 'CSV', cls: 'fb-ext-excel' }, + pdf: { label: 'PDF', cls: 'fb-ext-pdf' }, + sql: { label: 'SQL', cls: 'fb-ext-sql' }, + dxf: { label: 'DXF', cls: 'fb-ext-dxf' }, + txt: { label: '文本', cls: 'fb-ext-txt' }, + json: { label: 'JSON', cls: 'fb-ext-txt' }, +}; + +function extOf(name: string): string { + const idx = name.lastIndexOf('.'); + return idx > 0 ? name.slice(idx + 1).toLowerCase() : ''; +} + +function formatSize(size: number | null): string { + if (size == null) return ''; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / 1024 / 1024).toFixed(1)} MB`; +} + +function formatTime(mtime: number | null): string { + if (mtime == null) return ''; + const d = new Date(mtime); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** 统计当前目录里的业务文件(摘要行:Excel×3 · PDF×2 · SQL×1) */ +function summarizeFiles(entries: DirEntry[]): string { + const counts = new Map(); + for (const e of entries) { + if (e.type !== 'file') continue; + const style = EXT_STYLE[extOf(e.name)]; + if (!style) continue; + counts.set(style.label, (counts.get(style.label) ?? 0) + 1); + } + return [...counts.entries()].map(([label, n]) => `${label}×${n}`).join(' · '); +} + +export function FolderBrowserDialog(props: { + open: boolean; + initialPath?: string; + onCancel: () => void; + onSelect: (dirPath: string) => void; +}) { + const [listing, setListing] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const load = useCallback(async (dirPath: string) => { + const bridge = window.apsDesktop; + if (!bridge?.listDirectory) { + setError('当前环境不支持目录浏览(仅桌面端可用),请直接粘贴路径。'); + return; + } + setLoading(true); + setError(''); + try { + const result = await bridge.listDirectory(dirPath); + setListing(result); + if (result.error) setError(result.error); + } catch (err) { + setError(err instanceof Error ? err.message : '目录读取失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!props.open) return; + setListing(null); + void load(props.initialPath?.trim() || ''); + }, [props.open, props.initialPath, load]); + + const currentPath = listing?.path ?? ''; + const summary = useMemo( + () => (listing ? summarizeFiles(listing.entries) : ''), + [listing], + ); + + if (!props.open) return null; + + return ( +
+
e.stopPropagation()}> +
选择工程目录
+ +
+ + +
+ {currentPath || '请选择磁盘或粘贴路径'} +
+
+ +
+ {loading &&
正在读取…
} + {!loading && error &&
{error}
} + {!loading && !error && listing && listing.entries.length === 0 && ( +
空目录(可直接选择此文件夹)
+ )} + {!loading && listing?.entries.map(entry => ( + entry.type === 'dir' ? ( + + ) : ( +
+ {(() => { + const style = EXT_STYLE[extOf(entry.name)]; + return ( + + {style?.label ?? (extOf(entry.name).toUpperCase() || '文件')} + + ); + })()} + {entry.name} + {formatTime(entry.mtime)} + {formatSize(entry.size)} +
+ ) + ))} + {listing?.truncated && ( +
条目过多,仅显示前 2000 项
+ )} +
+ +
+ {summary ? `本目录包含:${summary}` : '本目录暂无 Excel/PDF/SQL 等业务文件'} +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index e234f8b..1e9fe23 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1699,6 +1699,37 @@ html.aps-desktop .app-menubar-spacer, .dlg-input:focus { border-color: var(--accent); background: #fff; box-shadow: 0 0 0 3px var(--accent-soft); } .dlg-actions { display: flex; justify-content: flex-end; gap: 8px; } +/* ---------- 内嵌工程目录浏览器(FolderBrowser) ---------- */ +.fb-card { display: flex; flex-direction: column; max-height: min(70vh, 560px); position: relative; } +.fb-toolbar { display: flex; align-items: center; gap: 8px; margin: 4px 0 10px; } +.fb-toolbar .btn-ghost { flex: 0 0 auto; height: 34px; margin-top: 0; padding: 0 12px; font-size: 13px; } +.fb-path { flex: 1 1 auto; min-width: 0; font-size: 13px; color: var(--muted, #6b675f); + background: var(--panel, #f5f4f1); border: 1px solid var(--border, #e3e0d9); border-radius: 8px; + padding: 7px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; } +.fb-list { flex: 1 1 auto; min-height: 240px; max-height: 380px; overflow-y: auto; + border: 1px solid var(--border, #e3e0d9); border-radius: 10px; background: #fff; } +.fb-empty { padding: 28px 16px; text-align: center; color: var(--muted, #6b675f); font-size: 13px; } +.fb-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 12px; + border: none; background: none; border-bottom: 1px solid var(--border, #f0eee8); + font-size: 13px; text-align: left; } +.fb-row:last-child { border-bottom: none; } +.fb-row-dir { cursor: pointer; color: var(--text, #2b2925); } +.fb-row-dir:hover { background: var(--accent-soft, #fdeee2); } +.fb-row-file { color: var(--text, #2b2925); cursor: default; } +.fb-icon { flex: 0 0 auto; } +.fb-ext { flex: 0 0 44px; text-align: center; font-size: 11px; font-weight: 600; + border-radius: 6px; padding: 2px 0; } +.fb-ext-excel { background: #e7f4ea; color: #1e7d43; } +.fb-ext-pdf { background: #fdeaea; color: #c13530; } +.fb-ext-sql { background: #e8f0fd; color: #2f5fc4; } +.fb-ext-dxf { background: #f1eafd; color: #6d3fc0; } +.fb-ext-txt { background: #f0eee8; color: #6b675f; } +.fb-ext-other { background: #f0eee8; color: #8a867d; } +.fb-name { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.fb-meta { flex: 0 0 auto; color: var(--muted, #9a968d); font-size: 12px; } +.fb-size { width: 72px; text-align: right; } +.fb-summary { margin: 10px 0; font-size: 12px; color: var(--muted, #6b675f); } + /* ---------- 治理控制台(Codex 设置页式覆盖层,§6.10) ---------- */ .gov-backdrop { position: fixed; inset: 0; z-index: 900; background: rgba(31,30,27,.30); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center;