feat: MD编辑模式(预览/编辑切换+保存回磁盘,新增POST /api/worktable/write路由)

This commit is contained in:
Aisland
2026-08-18 00:44:48 +08:00
parent 07e6a5e49f
commit 20f34fecbf
6 changed files with 152 additions and 29 deletions
+8
View File
@@ -65,6 +65,10 @@ export const zh = {
'pane.openLater': '该类型暂不支持预览',
'file.loading': '加载中…',
'file.fail': '文件读取失败',
'file.preview': '预览',
'file.edit': '编辑',
'file.save': '保存',
'file.saveFail': '保存失败',
'pane.gitNotRepo': '当前目录不是 git 仓库',
'pane.gitClean': '工作区干净',
'pane.jobsEmpty': '当前会话暂无后台任务',
@@ -144,6 +148,10 @@ export const en = {
'pane.openLater': 'Preview not supported for this type',
'file.loading': 'Loading…',
'file.fail': 'Failed to read file',
'file.preview': 'Preview',
'file.edit': 'Edit',
'file.save': 'Save',
'file.saveFail': 'Save failed',
'pane.gitNotRepo': 'Not a git repository',
'pane.gitClean': 'Working tree clean',
'pane.jobsEmpty': 'No background jobs in the current session',
+51 -14
View File
@@ -1121,10 +1121,14 @@ function FileViewer(props: { path: string }) {
return <TextViewer path={props.path} fileUrl={fileUrl} isMd={MD_EXTS.test('.' + ext)} />
}
/** MD/TXT 文本预览(fetch 原文 → MD 渲染或 <pre> 等宽展示) */
/** MD/TXT 文本预览(fetch 原文 → MD 渲染或 <pre> 等宽展示);MD 支持编辑/预览自由切换并可保存回磁盘 */
function TextViewer(props: { path: string; fileUrl: string; isMd: boolean }) {
const [text, setText] = useState<string | null>(null)
const [error, setError] = useState('')
const [mode, setMode] = useState<'preview' | 'edit'>('preview')
const [draft, setDraft] = useState('')
const [saving, setSaving] = useState(false)
const [saveFail, setSaveFail] = useState(false)
useEffect(() => {
let dead = false
setText(null)
@@ -1138,6 +1142,25 @@ function TextViewer(props: { path: string; fileUrl: string; isMd: boolean }) {
.catch((e) => { if (!dead) setError(String(e)) })
return () => { dead = true }
}, [props.fileUrl])
const enterEdit = () => { setDraft(text ?? ''); setSaveFail(false); setMode('edit') }
const save = async () => {
setSaving(true)
setSaveFail(false)
try {
const r = await fetch('/api/worktable/write', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ path: props.path, content: draft }),
})
if (!r.ok) throw new Error('HTTP ' + r.status)
setText(draft)
setMode('preview')
} catch {
setSaveFail(true)
} finally {
setSaving(false)
}
}
if (error) {
return <div className="dsh-wt_paneWip"><span className="dsh-wt_paneWipText">{T('file.fail')}:{error}</span></div>
}
@@ -1146,19 +1169,33 @@ function TextViewer(props: { path: string; fileUrl: string; isMd: boolean }) {
}
if (props.isMd) {
return (
<div className="dsh-wt_fileView">
<div
className="dsh-wt_md"
dangerouslySetInnerHTML={{ __html: mdRenderer.render(text) }}
onClick={(e: any) => {
const a = e.target && e.target.closest ? (e.target.closest('a') as HTMLAnchorElement | null) : null
if (!a) return
e.preventDefault()
const href = a.getAttribute('href') || ''
if (/^(https?:|mailto:)/i.test(href)) window.open(href, '_blank', 'noopener')
}}
/>
</div>
<>
<div className="dsh-wt_mdBar">
<button type="button" className={'dsh-wt_mdBtn' + (mode === 'preview' ? ' dsh-wt_mdBtnOn' : '')} onClick={() => setMode('preview')}>{T('file.preview')}</button>
<button type="button" className={'dsh-wt_mdBtn' + (mode === 'edit' ? ' dsh-wt_mdBtnOn' : '')} onClick={enterEdit}>{T('file.edit')}</button>
{mode === 'edit' && (
<button type="button" className="dsh-wt_mdSave" disabled={saving} onClick={save}>{saving ? '…' : T('file.save')}</button>
)}
{saveFail && <span className="dsh-wt_mdMsg">{T('file.saveFail')}</span>}
</div>
{mode === 'edit'
? <textarea className="dsh-wt_mdEdit" value={draft} spellCheck={false} onChange={(e) => setDraft(e.target.value)} />
: (
<div className="dsh-wt_fileView">
<div
className="dsh-wt_md"
dangerouslySetInnerHTML={{ __html: mdRenderer.render(text) }}
onClick={(e: any) => {
const a = e.target && e.target.closest ? (e.target.closest('a') as HTMLAnchorElement | null) : null
if (!a) return
e.preventDefault()
const href = a.getAttribute('href') || ''
if (/^(https?:|mailto:)/i.test(href)) window.open(href, '_blank', 'noopener')
}}
/>
</div>
)}
</>
)
}
return <div className="dsh-wt_fileView"><pre className="dsh-wt_txt">{text}</pre></div>
+9
View File
@@ -84,6 +84,15 @@ export const css = [
// 文件预览:文本/MD 滚动容器 + MD 排版 + 图片居中
'.dsh-wt_fileView{flex:1;min-height:0;overflow:auto;padding:12px 14px;background:var(--dsw-alias-bg-base,#0b0e14);color:var(--dsw-alias-label-primary,#e6e8eb);font-size:12.5px;line-height:1.7}',
'.dsh-wt_txt{margin:0;font-family:var(--dsw-font-mono,Consolas,Menlo,monospace);font-size:12px;line-height:1.65;white-space:pre-wrap;word-break:break-word}',
// MD 编辑/预览切换工具栏
'.dsh-wt_mdBar{flex:none;display:flex;align-items:center;gap:6px;padding:4px 8px;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02))}',
'.dsh-wt_mdBtn{flex:none;padding:3px 10px;border:1px solid transparent;border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font:inherit;font-size:11px;line-height:16px;cursor:pointer}',
'.dsh-wt_mdBtn:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06))}',
'.dsh-wt_mdBtnOn{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
'.dsh-wt_mdSave{margin-left:auto;padding:3px 12px;border:none;border-radius:5px;background:var(--dsw-alias-state-accent-primary,#4f8ef7);color:#fff;font:inherit;font-size:11px;cursor:pointer}',
'.dsh-wt_mdSave:disabled{opacity:.6;cursor:default}',
'.dsh-wt_mdMsg{font-size:11px;color:var(--dsw-alias-state-danger,#e5484d)}',
'.dsh-wt_mdEdit{flex:1;min-height:0;width:100%;box-sizing:border-box;padding:12px 14px;background:var(--dsw-alias-bg-base,#0b0e14);color:var(--dsw-alias-label-primary,#e6e8eb);border:none;outline:none;resize:none;font-family:var(--dsw-font-mono,Consolas,Menlo,monospace);font-size:12px;line-height:1.7}',
'.dsh-wt_md h1,.dsh-wt_md h2,.dsh-wt_md h3,.dsh-wt_md h4{margin:14px 0 8px;line-height:1.3;font-weight:600;color:var(--dsw-alias-label-primary,#e6e8eb)}',
'.dsh-wt_md h1{font-size:20px;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);padding-bottom:6px}',
'.dsh-wt_md h2{font-size:16.5px;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);padding-bottom:5px}',