From 2dadeed75907495081135e1ad9e2fb51e55bc0fd Mon Sep 17 00:00:00 2001 From: yuzhe Date: Thu, 23 Jul 2026 15:09:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E5=85=BC=E5=AE=B9=20HTTP=20?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E5=B9=B6=E5=9B=BA=E5=8C=96=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E5=90=AF=E5=81=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 2 - .gitignore | 4 -- README.md | 7 ++- docs/handoff.md | 2 +- docs/operator-runbook.md | 2 +- package.json | 4 ++ scripts/dev.ps1 | 111 +++++++++++++++++++++++++++++++++++++++ src/lib/utils.ts | 30 +++++++++++ src/pages/Management.tsx | 6 ++- src/pages/Project.tsx | 6 ++- 10 files changed, 160 insertions(+), 14 deletions(-) create mode 100644 scripts/dev.ps1 diff --git a/.dockerignore b/.dockerignore index d36195f..5305ce4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -31,5 +31,3 @@ temp .idea .trae .vercel -atelier-notes.zip -= diff --git a/.gitignore b/.gitignore index 2c385b0..1c584de 100644 --- a/.gitignore +++ b/.gitignore @@ -65,7 +65,3 @@ Thumbs.db *.njsproj *.sln *.sw? - -# Local source archives and accidental files -/atelier-notes.zip -/= diff --git a/README.md b/README.md index f4dc98d..fa0f4c3 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,14 @@ ```bash pnpm install -pnpm dev +pnpm dev:start ``` - 前端:http://localhost:5180 -- API:http://localhost:3010 +- 后端:http://localhost:3010 +- 重启:`pnpm dev:restart` +- 停止:`pnpm dev:stop` +- 查看状态:`pnpm dev:status` - 健康检查:http://localhost:3010/api/health 未配置 `DATABASE_URL` 时使用 `data/app.db`;本地上传文件保存在 `uploads/`。两者均包含运行数据或用户文件,已排除在 Git 之外。 diff --git a/docs/handoff.md b/docs/handoff.md index a5ea088..e3c8367 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -26,7 +26,7 @@ - 已上传作品在所有阶段的图片重新排序 - 在线人员状态、实时变更通知和并发冲突保护 - HEIC/HEIF 转换、多尺寸缩略图和 EXIF 定位信息清理 -- 真实腾讯云、生产 PostgreSQL、HTTPS 和备份恢复演练 +- 独立生产 COS 桶、生产 PostgreSQL、HTTPS 和备份恢复演练 ## 上线门槛 diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 33f5a20..fc8494a 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -51,7 +51,7 @@ COS 使用公开 URL。公共访问域名和 CDN 域名必须能够解析到公 JSON URL 导入依赖活动 COS 配置。同一 COS/CDN 域名的图片直接使用;其他域名会下载并按内容哈希写入 `/imports/`。服务会拒绝内网地址、非图片响应和超过 20 MB 的文件,因此部署网络必须允许访问确需导入的公开图片源。 -使用 `pnpm server:prod` 运行本地 API 时不会监听源码变化;后端代码更新后必须重启进程。日常开发应使用 `pnpm server:dev` 或 `pnpm dev`。 +使用 `pnpm server:prod` 运行本地 API 时不会监听源码变化;后端代码更新后必须重启进程。日常开发统一使用 `pnpm dev:start` 启动前后端,使用 `pnpm dev:restart` 重启、`pnpm dev:stop` 停止、`pnpm dev:status` 检查 5180 和 3010。启动脚本把受管进程 PID 写入已忽略的 `tmp/dev.pid`,避免重启时误杀其他 Node 进程。 ## Agent 上传 Skill 维护 diff --git a/package.json b/package.json index 0c69576..344cc78 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "test:postgres-runtime": "tsx scripts/test-postgres-runtime.ts", "test:collection-status": "tsx scripts/test-sqlite-collection-status.ts", "test:review-rounds": "tsx scripts/test-sqlite-review-rounds.ts", + "dev:start": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/dev.ps1 start", + "dev:restart": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/dev.ps1 restart", + "dev:stop": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/dev.ps1 stop", + "dev:status": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/dev.ps1 status", "dev": "concurrently \"npm run client:dev\" \"npm run server:dev\"" }, "dependencies": { diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 new file mode 100644 index 0000000..46d9058 --- /dev/null +++ b/scripts/dev.ps1 @@ -0,0 +1,111 @@ +param( + [ValidateSet('start', 'restart', 'stop', 'status')] + [string]$Action = 'start' +) + +$ErrorActionPreference = 'Stop' +$projectRoot = Split-Path -Parent $PSScriptRoot +$runtimeDir = Join-Path $projectRoot 'tmp' +$pidFile = Join-Path $runtimeDir 'dev.pid' + +function Get-TrackedProcessId { + if (-not (Test-Path -LiteralPath $pidFile)) { + return $null + } + + $value = (Get-Content -LiteralPath $pidFile -Raw).Trim() + try { + if ($value -match '^\d+$') { + $processId = [int]$value + $startTimeTicks = $null + } else { + $state = $value | ConvertFrom-Json + $processId = [int]$state.pid + $startTimeTicks = [long]$state.start_time_ticks + } + } catch { + Remove-Item -LiteralPath $pidFile -Force + return $null + } + + $process = Get-Process -Id $processId -ErrorAction SilentlyContinue + if (-not $process -or ($startTimeTicks -and $process.StartTime.ToUniversalTime().Ticks -ne $startTimeTicks)) { + Remove-Item -LiteralPath $pidFile -Force + return $null + } + + return $processId +} + +function Stop-TrackedProject { + $processId = Get-TrackedProcessId + if (-not $processId) { + Write-Host 'No managed Delivery Desk process is running.' -ForegroundColor Yellow + return + } + + Write-Host "Stopping Delivery Desk process tree (PID $processId)..." + & taskkill.exe /PID $processId /T /F | Out-Null + Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue + Start-Sleep -Milliseconds 300 + Write-Host 'Delivery Desk stopped.' -ForegroundColor Green +} + +function Test-Endpoint([string]$Url) { + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri $Url -TimeoutSec 2 + return $response.StatusCode -eq 200 + } catch { + return $false + } +} + +if ($Action -eq 'status') { + $processId = Get-TrackedProcessId + $frontendReady = Test-Endpoint 'http://127.0.0.1:5180/' + $backendReady = Test-Endpoint 'http://127.0.0.1:3010/api/health' + $processStatus = if ($processId) { "running (PID $processId)" } else { 'not managed' } + $frontendStatus = if ($frontendReady) { 'ready' } else { 'unavailable' } + $backendStatus = if ($backendReady) { 'ready' } else { 'unavailable' } + Write-Host "Process: $processStatus" + Write-Host "Frontend 5180: $frontendStatus" + Write-Host "Backend 3010: $backendStatus" + if ($frontendReady -and $backendReady) { exit 0 } else { exit 1 } +} + +if ($Action -eq 'stop') { + Stop-TrackedProject + exit 0 +} + +$trackedProcessId = Get-TrackedProcessId +if ($trackedProcessId -and $Action -eq 'start') { + Write-Host "Delivery Desk is already running (PID $trackedProcessId). Use pnpm dev:restart to restart it." -ForegroundColor Yellow + exit 0 +} + +if ($Action -eq 'restart') { + Stop-TrackedProject +} + +$pnpm = Get-Command pnpm -ErrorAction Stop +New-Item -ItemType Directory -Path $runtimeDir -Force | Out-Null +$currentProcess = Get-Process -Id $PID +@{ + pid = $PID + start_time_ticks = $currentProcess.StartTime.ToUniversalTime().Ticks +} | ConvertTo-Json -Compress | Set-Content -LiteralPath $pidFile -Encoding ascii -NoNewline +Set-Location -LiteralPath $projectRoot + +Write-Host 'Starting Delivery Desk...' -ForegroundColor Cyan +Write-Host 'Frontend: http://localhost:5180 Backend: http://localhost:3010' +Write-Host 'Press Ctrl+C to stop.' + +try { + & $pnpm.Source dev + exit $LASTEXITCODE +} finally { + if ((Get-TrackedProcessId) -eq $PID) { + Remove-Item -LiteralPath $pidFile -Force + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..d6b8dd3 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,33 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export async function copyText(text: string): Promise { + if (window.isSecureContext && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // Fall through for browsers that expose Clipboard API but reject the write. + } + } + + const textarea = document.createElement('textarea') + textarea.value = text + textarea.readOnly = true + textarea.style.position = 'fixed' + textarea.style.inset = '0 auto auto -9999px' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + textarea.focus() + textarea.select() + textarea.setSelectionRange(0, textarea.value.length) + + try { + return document.execCommand('copy') + } catch { + return false + } finally { + textarea.remove() + } +} diff --git a/src/pages/Management.tsx b/src/pages/Management.tsx index 2cba07e..6784526 100644 --- a/src/pages/Management.tsx +++ b/src/pages/Management.tsx @@ -4,6 +4,7 @@ import type { AuditLogEntry, ManagedApiKey, ManagedUser, OperationGroup, Project import { api, ApiError } from '@/api/client'; import { useAuthStore } from '@/store/useAuthStore'; import StorageSettings from '@/components/StorageSettings'; +import { copyText } from '@/lib/utils'; type Tab = 'groups' | 'accounts' | 'keys' | 'storage' | 'audit'; type Panel = 'group' | 'user' | 'key' | 'rename' | 'rename_group' | 'replace_admin' | 'reset' | null; @@ -31,6 +32,7 @@ export default function ManagementPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [revealedKey, setRevealedKey] = useState(''); + const [copyState, setCopyState] = useState<'idle' | 'copying' | 'success' | 'error'>('idle'); const [resetTarget, setResetTarget] = useState(null); const [renameTarget, setRenameTarget] = useState(null); const [replaceGroup, setReplaceGroup] = useState(null); @@ -108,7 +110,7 @@ export default function ManagementPage() { {loading ? : <> {tab === 'groups' && setPanel('group')} onRename={(item)=>{setRenameGroup(item);setRenameGroupValue(item.name);setPanel('rename_group')}} onShowAccounts={(item) => { setAccountGroupFilter(String(item.id)); setTab('accounts'); }} onReplace={(item) => { setReplaceGroup(item); setReplacementUserId(''); setPreviousAdminAction('demote'); setPanel('replace_admin'); }} onToggle={(item) => { if(item.status==='active'&&!window.confirm(`停用“${item.name}”将影响 ${item.active_user_count} 个启用账号、${item.project_count} 个项目和 ${item.customer_link_count} 个客户访问链接。确认继续吗?`))return;void run(() => api.setGroupStatus(item.id, item.status === 'active' ? 'disabled' : 'active').then(() => undefined)); }}/>} {tab === 'accounts' && setPanel('user')} onAudit={(item) => { setAuditUserId(item.id); setTab('audit'); }} onRename={(item) => { setRenameTarget(item); setRenameValue(item.display_name); setPanel('rename'); }} onToggle={(item) => void run(() => api.setUserStatus(item.id, item.status === 'active' ? 'disabled' : 'active').then(() => undefined))} onReset={(item) => { setResetTarget(item); setResetPassword(''); setPanel('reset'); }}/>} - {tab === 'keys' && { setRevealedKey(''); setPanel('key'); }} onRevoke={(item) => void run(() => api.revokeApiKey(item.id))}/>} + {tab === 'keys' && { setRevealedKey(''); setCopyState('idle'); setPanel('key'); }} onRevoke={(item) => void run(() => api.revokeApiKey(item.id))}/>} {tab === 'storage' && isPlatform && } {tab === 'audit' && item.id===auditUserId) : undefined} onClear={() => setAuditUserId(null)}/>} } @@ -124,7 +126,7 @@ export default function ManagementPage() { {panel === 'replace_admin' && replaceGroup && setPanel(null)}>
更换会一次性完成,不会出现两位组管理员;操作将写入审计日志。
void run(() => api.replaceGroupAdmin(replaceGroup.id,Number(replacementUserId),previousAdminAction).then(()=>undefined))}>确认更换管理员
} {panel === 'rename_group' && renameGroup && setPanel(null)}>setRenameGroupValue(e.target.value)}/>void renameOperationGroup()}>保存组名} - {panel === 'key' && { setPanel(null); setRevealedKey(''); }}>{revealedKey ?
{revealedKey}
: <> setKeyForm({ ...keyForm, name: e.target.value })} placeholder="例如:自动发布客户端"/>{!isPlatform && } { setSaving(true); setError(''); void api.createApiKey({ name: keyForm.name, project_id: keyForm.project_id ? Number(keyForm.project_id) : undefined }).then(({ token }) => { setRevealedKey(token); setKeyForm({ name: '', project_id: '' }); return load(); }).catch((reason) => setError(messageOf(reason))).finally(() => setSaving(false)); }}>生成密钥}
} + {panel === 'key' && { setPanel(null); setRevealedKey(''); setCopyState('idle'); }}>{revealedKey ?
{revealedKey}
: <> setKeyForm({ ...keyForm, name: e.target.value })} placeholder="例如:自动发布客户端"/>{!isPlatform && } { setSaving(true); setError(''); void api.createApiKey({ name: keyForm.name, project_id: keyForm.project_id ? Number(keyForm.project_id) : undefined }).then(({ token }) => { setRevealedKey(token); setCopyState('idle'); setKeyForm({ name: '', project_id: '' }); return load(); }).catch((reason) => setError(messageOf(reason))).finally(() => setSaving(false)); }}>生成密钥}
} ); } diff --git a/src/pages/Project.tsx b/src/pages/Project.tsx index 31099b3..cb08e5f 100644 --- a/src/pages/Project.tsx +++ b/src/pages/Project.tsx @@ -4,6 +4,7 @@ import { ArrowLeft, Copy, ImageIcon, KeyRound, MessageCircle, Pencil, Plus, Sear import type { Note, Project, ReviewStatus } from '@shared/types'; import { api } from '@/api/client'; import StatusBadge from '@/components/StatusBadge'; +import { copyText } from '@/lib/utils'; const projectStatus = { draft: '待提交', reviewing: '验收中', completed: '验收完毕', archived: '已归档' } as const; @@ -21,6 +22,7 @@ export default function ProjectPage() { const [accessPassword, setAccessPassword] = useState(''); const [expiresAt, setExpiresAt] = useState(''); const [message, setMessage] = useState(''); + const [copyState, setCopyState] = useState<'idle' | 'copying' | 'success' | 'error'>('idle'); const load = useCallback(async () => { const [nextProject, nextWorks] = await Promise.all([api.getProject(id), api.listProjectWorks(id)]); @@ -32,7 +34,7 @@ export default function ProjectPage() { if (!project) return
正在打开项目…
; const reviewUrl = `${window.location.origin}/review/${project.slug}`; - const openAccess = () => { setAccessEnabled(Boolean(project.customer_access_enabled)); setAccessPassword(''); setExpiresAt(project.access_expires_at?.slice(0, 16) || ''); setMessage(''); setAccessOpen(true); }; + const openAccess = () => { setAccessEnabled(Boolean(project.customer_access_enabled)); setAccessPassword(''); setExpiresAt(project.access_expires_at?.slice(0, 16) || ''); setMessage(''); setCopyState('idle'); setAccessOpen(true); }; return
返回项目

Project / {project.slug}

{projectStatus[project.review_status]}

{project.name}

{project.client_description}

@@ -44,7 +46,7 @@ export default function ProjectPage() { {editOpen && setEditOpen(false)}>