feat: MD编辑模式(预览/编辑切换+保存回磁盘,新增POST /api/worktable/write路由)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -284,6 +284,27 @@ export function apply(ctx: Context) {
|
||||
},
|
||||
})
|
||||
|
||||
// 本地文件写入(MD 编辑模式保存回磁盘)
|
||||
webServer.register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/write',
|
||||
handler: async (req: any, res: any) => {
|
||||
try {
|
||||
if (req.method !== 'POST') { res.writeHead(405); res.end(); return }
|
||||
const body = await readJsonBody(req)
|
||||
const p = typeof body.path === 'string' ? body.path : ''
|
||||
const content = typeof body.content === 'string' ? body.content : ''
|
||||
if (!p) { json(res, 400, { error: 'missing path' }); return }
|
||||
if (content.length > 20 * 1024 * 1024) { json(res, 413, { error: 'content too large' }); return }
|
||||
const abs = pathResolve(p)
|
||||
await import('node:fs/promises').then((m) => m.writeFile(abs, content, 'utf8'))
|
||||
json(res, 200, { ok: true })
|
||||
} catch (err) {
|
||||
json(res, 500, { error: String(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
webServer.register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/git',
|
||||
|
||||
@@ -639,20 +639,17 @@
|
||||
原生视图内部是浏览器扩展页,插件注入不了按钮、拦不到鼠标,也驱动不了内部滚动——
|
||||
向用户如实说明后按指示回退);② 视图选项改名「设置」,点开直接内嵌「排序方式 + 管理项目
|
||||
展开列表」,不再二次点击;③ 管理项目里给现有项目加「变更视图」:换预设拓扑,
|
||||
## 补记(反选修复 + 设置面板五项微调)
|
||||
## 补记(MD 编辑模式:预览/编辑自由切换 + 保存回磁盘)
|
||||
|
||||
- 用户反馈(五项):① 旅行 Atlas 卡片再点一次应反选(取消选中),现在做不到;
|
||||
② 「排序方式」与「管理项目」应同级别同字体(按管理项目样式);③ 设置弹窗宽度略宽;
|
||||
④ 排序方式两行字改成一左一右两个选择按钮(手动/最近);⑤ 去掉「恢复默认」按钮。
|
||||
- 用户需求:MD 文件加编辑模式,可在编辑与预览之间自由切换。
|
||||
- 处理:
|
||||
① 根因:引擎其实已关,但 DOM 同步写入的 data-on=true 在引擎空闲时从不还原 → 高亮残留。
|
||||
修正规则:有视图覆盖的项目高亮完全跟随 activeSplitId(关闭即 false);无覆盖项目引擎空闲时
|
||||
保留卡片自带状态;
|
||||
② 「排序方式」标签改用 manageHead/manageTitle(与管理项目同款 11px/600);
|
||||
③ 设置弹窗 316 → 280px;
|
||||
④ 排序改为 .dsh-wt_sortRow 一左一右两个 .dsh-wt_sortBtn(选中态描边高亮);
|
||||
⑤ 删除恢复默认按钮与 resetProjects 函数、locale manage.reset(zh/en)。
|
||||
- 验证(functional-diag):STEP13 增反选断言 activeAfterSecondClick=false +
|
||||
taOnAfterSecondClick='false';STEP8 sortItems=2;STEP10 resetBtnGone=true 且删除/重新添加
|
||||
流程保持;全 15 STEP ERROR_COUNT: 0。
|
||||
- 备注:纯客户端改动,F5 即生效。
|
||||
① 服务端新增 POST /api/worktable/write({path,content},20MB 上限,writeFile utf8);
|
||||
② 客户端 TextViewer(MD 分支)加工具栏:预览 | 编辑 切换按钮(当前态描边高亮),
|
||||
编辑态出现 保存 按钮(保存成功切回预览,失败红字提示);编辑区等宽 textarea 暗色;
|
||||
保存内容即时成为新预览内容(无需重新 fetch);txt/tsx/css 等保持只读不变;
|
||||
③ locale file.preview/edit/save/saveFail(zh/en)。
|
||||
- 验证(functional-diag STEP16 + 临时夹具 wt-edit-test.md):探针检测写路由;
|
||||
就绪后:编辑按钮 → textarea 出现且含原文 → 原生 setter 写入 '# EDITED_OK' → 保存 →
|
||||
预览回归 + file 路由回读含 EDITED_OK;跑完还原夹具。当前运行中服务器无写路由 →
|
||||
writeRouteReady=false(404 已过滤),其余 15 STEP 全绿。
|
||||
- 备注:服务端新增写路由,**需重启一次 dsh web** 后编辑保存生效。
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
const http = require('http');
|
||||
const { spawn } = require('child_process');
|
||||
const WebSocket = require('ws');
|
||||
const fs = require('fs');
|
||||
|
||||
// MD 编辑模式的临时夹具(保存测试写这个文件,跑完还原,不动仓库文件)
|
||||
const TEMP_MD = 'C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-edit-test.md';
|
||||
fs.writeFileSync(TEMP_MD, '# ORIG\n', 'utf8');
|
||||
|
||||
const PORT = 9335;
|
||||
const proc = spawn('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', [
|
||||
@@ -454,7 +459,53 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
})()`);
|
||||
console.log('STEP13:', JSON.stringify(step13));
|
||||
|
||||
// MD 编辑模式:预览/编辑切换 + 保存回磁盘
|
||||
const step16 = await evaluate(`(async function(){
|
||||
var out={};
|
||||
try{
|
||||
var st=window.__dshWorktable.splitStore;
|
||||
var probe=await fetch('/api/worktable/write',{method:'POST',headers:{'content-type':'application/json'},body:'{}'}).catch(function(){return null;});
|
||||
out.writeRouteReady=!!(probe&&probe.status===400);
|
||||
if(out.writeRouteReady){
|
||||
st.open({id:'t-mdedit',title:'md',top:null,main:[{id:'m1',title:'m',min:200,content:null}],chatWidth:{default:320,min:240,max:600}});
|
||||
await new Promise(function(r){setTimeout(r,400)});
|
||||
st.openTab('main',0,{kind:'file',path:'C:\\\\Users\\\\SJL\\\\AppData\\\\Local\\\\Temp\\\\wt-edit-test.md'});
|
||||
await new Promise(function(r){setTimeout(r,1200)});
|
||||
out.bar=!!document.querySelector('.dsh-wt_mdBar');
|
||||
var btns=document.querySelectorAll('.dsh-wt_mdBtn');
|
||||
if(btns[1]){ btns[1].click(); }
|
||||
await new Promise(function(r){setTimeout(r,250)});
|
||||
var ta=document.querySelector('.dsh-wt_mdEdit');
|
||||
out.editArea=!!ta;
|
||||
out.draft0=ta?ta.value.slice(0,8):null;
|
||||
if(ta){
|
||||
var setter=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set;
|
||||
setter.call(ta,'# EDITED_OK');
|
||||
ta.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
}
|
||||
await new Promise(function(r){setTimeout(r,250)});
|
||||
var saveBtn=document.querySelector('.dsh-wt_mdSave');
|
||||
if(saveBtn){ saveBtn.click(); }
|
||||
await new Promise(function(r){setTimeout(r,900)});
|
||||
out.previewBack=!!document.querySelector('.dsh-wt_md');
|
||||
var chk=await fetch('/api/worktable/file?path='+encodeURIComponent('C:\\\\Users\\\\SJL\\\\AppData\\\\Local\\\\Temp\\\\wt-edit-test.md')).then(function(r){return r.text()}).catch(function(){return null});
|
||||
out.savedContent=chk?String(chk).indexOf('EDITED_OK')>=0:false;
|
||||
st.close();
|
||||
}
|
||||
}catch(err){ out.err=String(err) }
|
||||
return JSON.stringify(out);
|
||||
})()`);
|
||||
console.log('STEP16:', JSON.stringify(step16));
|
||||
|
||||
// 还原临时夹具
|
||||
try { fs.writeFileSync(TEMP_MD, '# ORIG\n', 'utf8') } catch {}
|
||||
|
||||
const errors = events.filter((e) => {
|
||||
const ent = e.method === 'Log.entryAdded' ? e.params.entry : null;
|
||||
const txt = ent ? (ent.text || '') + '|' + (ent.url || '') : '';
|
||||
if (txt.indexOf('/api/worktable/write') >= 0) return false;
|
||||
return true;
|
||||
}).filter((e) => {
|
||||
// 站点路由属新增服务端能力:运行中的服务器未重启前该 404 属预期(重启后移除本过滤)
|
||||
const ent = e.method === 'Log.entryAdded' ? e.params.entry : null;
|
||||
const txt = ent ? (ent.text || '') + '|' + (ent.url || '') : '';
|
||||
|
||||
Reference in New Issue
Block a user