fix(desktop): 工程目录选择可见业务文件——内嵌目录浏览器取代原生纯文件夹选择框

原生 dialog.showOpenDialog(openDirectory) 只列文件夹,用户无法确认
目录里的 Excel/PDF/SQL 等排产数据文件。新增 aps:list-directory IPC
(只读列举、盘符根目录、条目上限、不可读显式报错),Web 端新建项目
「浏览…」改开内嵌浏览器:文件夹可下钻、文件按类型着色展示(Excel/
PDF/SQL/DXF)并带大小/日期,底部汇总本目录业务文件计数。旧版桌面桥
回退原生选择框。tsc + vite build + 桌面 Node 测试 48 全过。
This commit is contained in:
z.zhang 2026-10-09 14:00:34 +08:00
parent e0cf0f30f6
commit 1543e09eeb
6 changed files with 306 additions and 0 deletions

View File

@ -604,6 +604,75 @@ ipcMain.handle('aps:pick-directory', async (event) => {
return result.filePaths[0]; 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) => { ipcMain.on('aps:toggle-devtools', (event) => {
if (!isTrustedIpcEvent(event)) return; if (!isTrustedIpcEvent(event)) return;
const wc = event.sender; const wc = event.sender;

View File

@ -34,6 +34,9 @@ try {
async pickDirectory() { async pickDirectory() {
return ipcRenderer.invoke('aps:pick-directory'); return ipcRenderer.invoke('aps:pick-directory');
}, },
async listDirectory(dirPath) {
return ipcRenderer.invoke('aps:list-directory', dirPath || '');
},
/** 离线升级入口(updater.cjs):检查更新 / 应用升级(失败自动回滚)/ 手动回滚 */ /** 离线升级入口(updater.cjs):检查更新 / 应用升级(失败自动回滚)/ 手动回滚 */
checkForUpdate(options) { checkForUpdate(options) {

View File

@ -11,6 +11,14 @@ export interface ApsDesktopBridge {
openSkills?: () => void; openSkills?: () => void;
openSessions?: () => void; openSessions?: () => void;
pickDirectory?: () => Promise<string | null>; pickDirectory?: () => Promise<string | null>;
/** 内嵌工程目录浏览器:列目录内容(目录 + 文件),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; toggleDevTools?: () => void;
toggleFullScreen?: () => void; toggleFullScreen?: () => void;
leaveFullScreen?: () => void; leaveFullScreen?: () => void;

View File

@ -3,6 +3,7 @@
// 替代 window.prompt / confirm:遮罩 + 卡片,风格对齐设计系统 // 替代 window.prompt / confirm:遮罩 + 卡片,风格对齐设计系统
// ============================================================ // ============================================================
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { FolderBrowserDialog } from './FolderBrowser';
/** 输入名对话框(新建对话/文件/重命名) */ /** 输入名对话框(新建对话/文件/重命名) */
export function NameDialog(props: { export function NameDialog(props: {
@ -71,6 +72,7 @@ export function ProjectCreateDialog(props: {
}) { }) {
const [name, setName] = useState('新工厂排产项目'); const [name, setName] = useState('新工厂排产项目');
const [workDir, setWorkDir] = useState(''); const [workDir, setWorkDir] = useState('');
const [browserOpen, setBrowserOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const submittingRef = useRef(false); const submittingRef = useRef(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@ -82,6 +84,7 @@ export function ProjectCreateDialog(props: {
setName('新工厂排产项目'); setName('新工厂排产项目');
setWorkDir(''); setWorkDir('');
setError(''); setError('');
setBrowserOpen(false);
requestAnimationFrame(() => { requestAnimationFrame(() => {
inputRef.current?.focus(); inputRef.current?.focus();
inputRef.current?.select(); inputRef.current?.select();
@ -91,6 +94,11 @@ export function ProjectCreateDialog(props: {
if (!props.open) return null; if (!props.open) return null;
const pickDir = async () => { const pickDir = async () => {
// 优先内嵌浏览器(可见 Excel/PDF/SQL 等文件);旧版桌面桥回退原生选择框
if (window.apsDesktop?.listDirectory) {
setBrowserOpen(true);
return;
}
const dir = await window.apsDesktop?.pickDirectory?.(); const dir = await window.apsDesktop?.pickDirectory?.();
if (dir) setWorkDir(dir); if (dir) setWorkDir(dir);
}; };
@ -158,6 +166,12 @@ export function ProjectCreateDialog(props: {
{submitting ? '正在准备项目…' : error ? '重试' : '创建'} {submitting ? '正在准备项目…' : error ? '重试' : '创建'}
</button> </button>
</div> </div>
<FolderBrowserDialog
open={browserOpen}
initialPath={workDir}
onCancel={() => setBrowserOpen(false)}
onSelect={(dir) => { setWorkDir(dir); setBrowserOpen(false); }}
/>
</div> </div>
</div> </div>
); );

View File

@ -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<string, { label: string; cls: string }> = {
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<string, number>();
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<DirListing | null>(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 (
<div className="dlg-backdrop" onMouseDown={props.onCancel}>
<div className="dlg-card dlg-card-wide fb-card" role="dialog" aria-modal="true"
aria-label="选择工程目录" aria-busy={loading}
onMouseDown={e => e.stopPropagation()}>
<div className="dlg-title">选择工程目录</div>
<div className="fb-toolbar">
<button type="button" className="btn-ghost" title="此电脑(盘符)"
onClick={() => void load('')}>此电脑</button>
<button type="button" className="btn-ghost" title="上级目录"
disabled={!listing?.parent}
onClick={() => listing?.parent && void load(listing.parent)}>↑ 上级</button>
<div className="fb-path" title={currentPath}>
{currentPath || '请选择磁盘或粘贴路径'}
</div>
</div>
<div className="fb-list" role="listbox" aria-label="目录内容">
{loading && <div className="fb-empty">正在读取…</div>}
{!loading && error && <div className="fb-empty" role="alert">{error}</div>}
{!loading && !error && listing && listing.entries.length === 0 && (
<div className="fb-empty">空目录(可直接选择此文件夹)</div>
)}
{!loading && listing?.entries.map(entry => (
entry.type === 'dir' ? (
<button key={entry.path} type="button" className="fb-row fb-row-dir"
onDoubleClick={() => void load(entry.path)}
onClick={() => void load(entry.path)}>
<span className="fb-icon" aria-hidden>📁</span>
<span className="fb-name">{entry.name}</span>
</button>
) : (
<div key={entry.path} className="fb-row fb-row-file">
{(() => {
const style = EXT_STYLE[extOf(entry.name)];
return (
<span className={`fb-ext ${style?.cls ?? 'fb-ext-other'}`}>
{style?.label ?? (extOf(entry.name).toUpperCase() || '文件')}
</span>
);
})()}
<span className="fb-name" title={entry.name}>{entry.name}</span>
<span className="fb-meta">{formatTime(entry.mtime)}</span>
<span className="fb-meta fb-size">{formatSize(entry.size)}</span>
</div>
)
))}
{listing?.truncated && (
<div className="fb-empty">条目过多,仅显示前 2000 项</div>
)}
</div>
<div className="fb-summary">
{summary ? `本目录包含:${summary}` : '本目录暂无 Excel/PDF/SQL 等业务文件'}
</div>
<div className="dlg-actions">
<button type="button" className="btn-ghost" onClick={props.onCancel}>取消</button>
<button type="button" className="btn-primary"
disabled={loading || !currentPath}
onClick={() => currentPath && props.onSelect(currentPath)}>
选择此文件夹
</button>
</div>
</div>
</div>
);
}

View File

@ -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-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; } .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) ---------- */ /* ---------- 治理控制台(Codex 设置页式覆盖层,§6.10) ---------- */
.gov-backdrop { position: fixed; inset: 0; z-index: 900; background: rgba(31,30,27,.30); .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; backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center;