Compare commits

...

3 Commits

Author SHA1 Message Date
yuzhe
850f213aa5 docs: 对齐数据库与 COS 配置说明 2026-07-24 16:40:55 +08:00
yuzhe
fd93c69245 fix(management): 联动对象存储连接状态 2026-07-24 16:24:42 +08:00
yuzhe
2dadeed759 fix(ui): 兼容 HTTP 复制并固化开发启停 2026-07-23 15:09:23 +08:00
13 changed files with 175 additions and 33 deletions

View File

@@ -31,5 +31,3 @@ temp
.idea
.trae
.vercel
atelier-notes.zip
=

View File

@@ -3,7 +3,8 @@ NODE_ENV=development
CORS_ORIGIN=http://localhost:5180
# 正式环境必须填写;本地 SQLite 模式可暂时留空。
DATABASE_URL=postgresql://delivery_desk:local_delivery_desk@127.0.0.1:5432/delivery_desk
DATABASE_URL=
# PostgreSQL 示例postgresql://delivery_desk:replace-with-a-password@127.0.0.1:5432/delivery_desk
PGSSL=disable
PG_POOL_MAX=10

4
.gitignore vendored
View File

@@ -65,7 +65,3 @@ Thumbs.db
*.njsproj
*.sln
*.sw?
# Local source archives and accidental files
/atelier-notes.zip
/=

View File

@@ -5,6 +5,6 @@
- 新接口使用 `/api/projects/:projectId/works``/api/works/:workId/rounds``notes``collections``versions` 路由只做一个兼容周期,不再扩展。
- 数据库结构变更必须同时更新 `api/db.ts``db/postgres/schema.sql` 和迁移验证。
- `data/``uploads/``.env*`、COS 凭证、数据库文件及用户上传内容不得提交。
- 本地开发可使用 SQLite正式部署使用 PostgreSQL。COS 配置只通过平台管理界面部署密钥注入。
- 本地开发可使用 SQLite正式部署使用 PostgreSQL。COS 凭证只通过平台管理界面录入;`COS_CONFIG_ENCRYPTION_KEY` 通过部署密钥注入。
- 不把 `.trae/` 中的早期原型文档作为现行依据;以 README、`docs/` 和当前代码为准。
- Agent 通过 API 上传作品或创建验收轮次时必须使用 `.agents/skills/upload-delivery-desk-work`;接口或层级变化后同步更新该 Skill并运行 `powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1` 重新打包。

View File

@@ -22,16 +22,19 @@
```bash
pnpm install
pnpm dev
pnpm dev:start
```
- 前端http://localhost:5180
- APIhttp://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 之外。
复制 `.env.example``.env` 后配置环境变量。生产环境至少需要 `DATABASE_URL``COS_CONFIG_ENCRYPTION_KEY` 和安全的初始管理员密码。真实 COS 凭证只能通过平台管理界面或部署密钥注入,不能提交到 Git。
复制 `.env.example``.env` 后配置环境变量。生产环境至少需要 `DATABASE_URL``COS_CONFIG_ENCRYPTION_KEY` 和安全的初始管理员密码。真实 COS 凭证只能通过平台管理界面入,不能提交到 Git`COS_CONFIG_ENCRYPTION_KEY` 应通过部署密钥注入
## PostgreSQL 与 Docker

View File

@@ -26,7 +26,7 @@
- 已上传作品在所有阶段的图片重新排序
- 在线人员状态、实时变更通知和并发冲突保护
- HEIC/HEIF 转换、多尺寸缩略图和 EXIF 定位信息清理
- 真实腾讯云、生产 PostgreSQL、HTTPS 和备份恢复演练
- 独立生产 COS 桶、生产 PostgreSQL、HTTPS 和备份恢复演练
## 上线门槛

View File

@@ -51,7 +51,7 @@ COS 使用公开 URL。公共访问域名和 CDN 域名必须能够解析到公
JSON URL 导入依赖活动 COS 配置。同一 COS/CDN 域名的图片直接使用;其他域名会下载并按内容哈希写入 `<path-prefix>/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 维护

View File

@@ -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": {

111
scripts/dev.ps1 Normal file
View File

@@ -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
}
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { CheckCircle2, Cloud, Database, Loader2, LockKeyhole, RadioTower, ShieldCheck } from 'lucide-react';
import type { StorageConfig } from '@shared/types';
import { api, ApiError } from '@/api/client';
@@ -8,10 +8,8 @@ const initialForm = {
path_prefix: 'delivery-desk', secret_id: '', secret_key: '',
};
export default function StorageSettings() {
const [items, setItems] = useState<StorageConfig[]>([]);
export default function StorageSettings({ items, onItemsChange }: { items: StorageConfig[]; onItemsChange: (items: StorageConfig[]) => void }) {
const [form, setForm] = useState(initialForm);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<'save' | 'test' | 'activate' | null>(null);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
@@ -19,12 +17,9 @@ export default function StorageSettings() {
const draft = useMemo(() => items.find((item) => item.status === 'draft'), [items]);
const load = async () => {
setLoading(true);
try { setItems(await api.listStorageConfigs()); }
try { onItemsChange(await api.listStorageConfigs()); }
catch (reason) { setError(messageOf(reason)); }
finally { setLoading(false); }
};
useEffect(() => { void load(); }, []);
const save = async () => {
setBusy('save'); setError(''); setNotice('');
@@ -55,8 +50,6 @@ export default function StorageSettings() {
finally { setBusy(null); }
};
if (loading) return <div className="grid gap-3 py-8">{[1, 2].map((item) => <div key={item} className="h-28 animate-pulse rounded-2xl bg-black/5"/>)}</div>;
return <section className="py-8">
<div className="mb-7"><p className="font-mono text-[10px] uppercase tracking-[.28em] text-[#d15f37]">Infrastructure / Object Storage</p><h2 className="mt-2 font-display text-4xl tracking-[-.04em]"> COS</h2><p className="mt-2 max-w-2xl text-sm leading-6 text-black/45">稿</p></div>
{error && <div className="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}

View File

@@ -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<boolean> {
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()
}
}

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ArrowRightLeft, ChevronDown, Copy, Fingerprint, History, KeyRound, Plus, RefreshCw, Search, ShieldCheck, Users, X } from 'lucide-react';
import type { AuditLogEntry, ManagedApiKey, ManagedUser, OperationGroup, Project } from '@shared/types';
import type { AuditLogEntry, ManagedApiKey, ManagedUser, OperationGroup, Project, StorageConfig } from '@shared/types';
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;
@@ -27,10 +28,12 @@ export default function ManagementPage() {
const [keys, setKeys] = useState<ManagedApiKey[]>([]);
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [storageConfigs, setStorageConfigs] = useState<StorageConfig[]>([]);
const [loading, setLoading] = useState(true);
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<ManagedUser | null>(null);
const [renameTarget, setRenameTarget] = useState<ManagedUser | null>(null);
const [replaceGroup, setReplaceGroup] = useState<OperationGroup | null>(null);
@@ -51,11 +54,12 @@ export default function ManagementPage() {
setLoading(true);
setError('');
try {
const [nextAccounts, nextKeys, nextLogs, nextProjects, nextGroups] = await Promise.all([
const [nextAccounts, nextKeys, nextLogs, nextProjects, nextGroups, nextStorageConfigs] = await Promise.all([
api.listManagedUsers(), api.listApiKeys(), api.listAuditLogs(), api.listProjects(),
isPlatform ? api.listGroups() : Promise.resolve([]),
isPlatform ? api.listStorageConfigs() : Promise.resolve([]),
]);
setAccounts(nextAccounts); setKeys(nextKeys); setLogs(nextLogs); setProjects(nextProjects); setGroups(nextGroups);
setAccounts(nextAccounts); setKeys(nextKeys); setLogs(nextLogs); setProjects(nextProjects); setGroups(nextGroups); setStorageConfigs(nextStorageConfigs);
} catch (reason) { setError(messageOf(reason)); }
finally { setLoading(false); }
}, [isPlatform, user]);
@@ -65,9 +69,9 @@ export default function ManagementPage() {
...(isPlatform ? [{ id: 'groups' as const, label: '运营组', count: groups.length }] : []),
{ id: 'accounts' as const, label: '账号', count: accounts.length },
{ id: 'keys' as const, label: 'API Key', count: keys.filter((item) => item.status === 'active').length },
...(isPlatform ? [{ id: 'storage' as const, label: '对象存储', count: 0 }] : []),
...(isPlatform ? [{ id: 'storage' as const, label: '对象存储', count: storageConfigs.some((item) => item.status === 'active') ? '已连接' : '未配置' }] : []),
{ id: 'audit' as const, label: '审计日志', count: logs.length },
], [accounts.length, groups.length, isPlatform, keys, logs.length]);
], [accounts.length, groups.length, isPlatform, keys, logs.length, storageConfigs]);
if (user?.role === 'operator') {
return <main className="mx-auto max-w-3xl px-5 py-24 text-center"><ShieldCheck className="mx-auto text-black/25"/><h1 className="mt-5 font-display text-4xl"></h1><p className="mt-3 text-sm text-black/45"></p></main>;
@@ -108,8 +112,8 @@ export default function ManagementPage() {
{loading ? <Loading /> : <>
{tab === 'groups' && <Groups groups={groups} accounts={accounts} onCreate={() => 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' && <Accounts accounts={accounts} groups={groups} groupFilter={accountGroupFilter} onGroupFilter={setAccountGroupFilter} currentId={user?.id} currentRole={user?.role} onCreate={() => 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' && <ApiKeys items={keys} onCreate={() => { setRevealedKey(''); setPanel('key'); }} onRevoke={(item) => void run(() => api.revokeApiKey(item.id))}/>}
{tab === 'storage' && isPlatform && <StorageSettings/>}
{tab === 'keys' && <ApiKeys items={keys} onCreate={() => { setRevealedKey(''); setCopyState('idle'); setPanel('key'); }} onRevoke={(item) => void run(() => api.revokeApiKey(item.id))}/>}
{tab === 'storage' && isPlatform && <StorageSettings items={storageConfigs} onItemsChange={setStorageConfigs}/>}
{tab === 'audit' && <AuditLogs items={logs} filteredUser={auditUserId ? accounts.find((item)=>item.id===auditUserId) : undefined} onClear={() => setAuditUserId(null)}/>}
</>}
@@ -124,7 +128,7 @@ export default function ManagementPage() {
{panel === 'replace_admin' && replaceGroup && <Modal title="更换组管理员" subtitle={`${replaceGroup.name} 当前管理员:${replaceGroup.group_admin_name || '未设置'}`} onClose={() => setPanel(null)}><FormField label="新组管理员" hint="从已启用的光影叙事中选择"><select value={replacementUserId} onChange={(e)=>setReplacementUserId(e.target.value)}><option value=""></option>{accounts.filter((item)=>item.group_id===replaceGroup.id&&item.role==='operator'&&item.status==='active').map((item)=><option key={item.id} value={item.id}>{item.display_name} · {item.username}</option>)}</select></FormField><FormField label="原管理员处理"><select value={previousAdminAction} onChange={(e)=>setPreviousAdminAction(e.target.value as 'demote'|'disable')}><option value="demote"></option><option value="disable"></option></select></FormField><div className="rounded-xl border border-[#d15f37]/15 bg-[#fff7f2] p-3 text-xs leading-5 text-[#91462e]"></div><Submit saving={saving} disabled={!replacementUserId} onClick={() => void run(() => api.replaceGroupAdmin(replaceGroup.id,Number(replacementUserId),previousAdminAction).then(()=>undefined))}></Submit></Modal>}
{panel === 'rename_group' && renameGroup && <Modal title="修改运营组名称" subtitle="只修改名称,不影响组内账号、项目、客户链接和权限。" onClose={()=>setPanel(null)}><FormField label="运营组名称" hint="240 个字符"><input autoFocus value={renameGroupValue} onChange={(e)=>setRenameGroupValue(e.target.value)}/></FormField><Submit saving={saving} disabled={renameGroupValue.trim().length<2||renameGroupValue.trim()===renameGroup.name} onClick={()=>void renameOperationGroup()}></Submit></Modal>}
{panel === 'key' && <Modal title={revealedKey ? '保存 API Key' : '创建 API Key'} subtitle={revealedKey ? '密钥只显示这一次。关闭前请复制到安全位置。' : isPlatform ? '平台级 Key 可用于外部系统创建项目。' : '项目级 Key 仅能操作绑定的项目。'} onClose={() => { setPanel(null); setRevealedKey(''); }}>{revealedKey ? <div><div className="break-all rounded-2xl bg-[#171714] p-5 font-mono text-xs leading-6 text-[#f2c38d]">{revealedKey}</div><button onClick={() => void navigator.clipboard.writeText(revealedKey)} className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-full border border-black/15 py-3 text-sm"><Copy size={14}/>复制密钥</button></div> : <><FormField label="Key 名称"><input value={keyForm.name} onChange={(e) => setKeyForm({ ...keyForm, name: e.target.value })} placeholder="例如:自动发布客户端"/></FormField>{!isPlatform && <FormField label="绑定项目"><select value={keyForm.project_id} onChange={(e) => setKeyForm({ ...keyForm, project_id: e.target.value })}><option value="">请选择</option>{projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></FormField>}<Submit saving={saving} disabled={!keyForm.name || (!isPlatform && !keyForm.project_id)} onClick={() => { 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)); }}></Submit></>}</Modal>}
{panel === 'key' && <Modal title={revealedKey ? '保存 API Key' : '创建 API Key'} subtitle={revealedKey ? '密钥只显示这一次。关闭前请复制到安全位置。' : isPlatform ? '平台级 Key 可用于外部系统创建项目。' : '项目级 Key 仅能操作绑定的项目。'} onClose={() => { setPanel(null); setRevealedKey(''); setCopyState('idle'); }}>{revealedKey ? <div><div className="break-all rounded-2xl bg-[#171714] p-5 font-mono text-xs leading-6 text-[#f2c38d]">{revealedKey}</div><button onClick={() => { setCopyState('copying'); void copyText(revealedKey).then((copied) => setCopyState(copied ? 'success' : 'error')).catch(() => setCopyState('error')); }} className={`mt-4 inline-flex w-full items-center justify-center gap-2 rounded-full border py-3 text-sm transition ${copyState === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : copyState === 'error' ? 'border-red-200 bg-red-50 text-red-700' : 'border-black/15'}`}><Copy size={14}/><span aria-live="polite">{copyState === 'copying' ? '复制中…' : copyState === 'success' ? '已复制' : copyState === 'error' ? '复制失败,请手动复制' : '复制密钥'}</span></button></div> : <><FormField label="Key 名称"><input value={keyForm.name} onChange={(e) => setKeyForm({ ...keyForm, name: e.target.value })} placeholder="例如:自动发布客户端"/></FormField>{!isPlatform && <FormField label="绑定项目"><select value={keyForm.project_id} onChange={(e) => setKeyForm({ ...keyForm, project_id: e.target.value })}><option value="">请选择</option>{projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></FormField>}<Submit saving={saving} disabled={!keyForm.name || (!isPlatform && !keyForm.project_id)} onClick={() => { 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)); }}></Submit></>}</Modal>}
</main>
);
}

View File

@@ -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 <main className="grid min-h-[60vh] place-items-center text-sm text-black/35"></main>;
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 <main>
<section className="border-b border-black/10 bg-[#171714] text-white"><div className="mx-auto max-w-[1500px] px-5 py-12 lg:px-10 lg:py-16"><Link to="/" className="inline-flex items-center gap-2 text-xs text-white/55"><ArrowLeft size={14}/></Link><div className="mt-9 grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end"><div><div className="flex flex-wrap items-center gap-3"><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ff795f]">Project / {project.slug}</p><span className="rounded-full border border-white/15 px-3 py-1 text-[10px] text-white/60">{projectStatus[project.review_status]}</span></div><h1 className="mt-4 font-display text-5xl tracking-[-.05em] sm:text-7xl">{project.name}</h1><p className="mt-5 max-w-2xl text-sm leading-7 text-white/55">{project.client_description}</p></div><div><div className="grid grid-cols-4 gap-5"><Metric n={project.work_count} label="全部"/><Metric n={project.pending_count} label="待验收"/><Metric n={project.changes_requested_count} label="需修改"/><Metric n={project.approved_count} label="已通过"/></div><div className="mt-6 flex flex-wrap justify-end gap-2"><button onClick={() => { setProjectName(project.name); setProjectDesc(project.client_description); setEditOpen(true); }} className="rounded-full border border-white/20 px-4 py-2 text-xs text-white/70"><Pencil className="mr-2 inline" size={13}/></button><button onClick={openAccess} className="rounded-full border border-white/20 px-4 py-2 text-xs text-white/70"><KeyRound className="mr-2 inline" size={13}/>访</button></div></div></div></div></section>
@@ -44,7 +46,7 @@ export default function ProjectPage() {
</section>
{editOpen && <Modal title="编辑项目" close={() => setEditOpen(false)}><label className="block text-xs text-black/45"><input className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={projectName} onChange={(event) => setProjectName(event.target.value)}/></label><label className="mt-4 block text-xs text-black/45"><textarea className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" rows={4} value={projectDesc} onChange={(event) => setProjectDesc(event.target.value)}/></label><button onClick={async () => { setProject(await api.updateProject(id, { name: projectName, client_description: projectDesc })); setEditOpen(false); }} className="mt-5 w-full rounded-full bg-black py-3 text-sm text-white"></button></Modal>}
{accessOpen && <Modal title="客户访问" close={() => setAccessOpen(false)}><div className="rounded-xl bg-black/[.04] p-3 text-xs break-all">{reviewUrl}</div><button onClick={() => void navigator.clipboard.writeText(reviewUrl)} className="mt-2 inline-flex items-center gap-2 text-xs text-black/50"><Copy size={12}/></button><label className="mt-5 flex items-center gap-3 text-sm"><input type="checkbox" checked={accessEnabled} onChange={(event) => setAccessEnabled(event.target.checked)}/>访</label><label className="mt-4 block text-xs text-black/45">访<input type="password" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={accessPassword} onChange={(event) => setAccessPassword(event.target.value)}/></label><label className="mt-4 block text-xs text-black/45"><input type="datetime-local" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)}/></label><button onClick={async () => { try { const updated = await api.updateCustomerAccess(id, { enabled: accessEnabled, password: accessPassword || undefined, expires_at: expiresAt || null }); setProject(updated); setMessage('客户访问设置已保存'); setAccessPassword(''); } catch (error) { setMessage(error instanceof Error ? error.message : '保存失败'); } }} className="mt-5 w-full rounded-full bg-black py-3 text-sm text-white"></button>{message && <p className="mt-3 text-center text-xs text-black/50">{message}</p>}</Modal>}
{accessOpen && <Modal title="客户访问" close={() => setAccessOpen(false)}><div className="rounded-xl bg-black/[.04] p-3 text-xs break-all">{reviewUrl}</div><button onClick={() => { setCopyState('copying'); void copyText(reviewUrl).then((copied) => setCopyState(copied ? 'success' : 'error')).catch(() => setCopyState('error')); }} className={`mt-2 inline-flex items-center gap-2 text-xs transition ${copyState === 'success' ? 'text-emerald-700' : copyState === 'error' ? 'text-red-600' : 'text-black/50'}`}><Copy size={12}/><span aria-live="polite">{copyState === 'copying' ? '复制中…' : copyState === 'success' ? '已复制' : copyState === 'error' ? '复制失败,请手动复制' : '复制链接'}</span></button><label className="mt-5 flex items-center gap-3 text-sm"><input type="checkbox" checked={accessEnabled} onChange={(event) => setAccessEnabled(event.target.checked)}/>访</label><label className="mt-4 block text-xs text-black/45">访<input type="password" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={accessPassword} onChange={(event) => setAccessPassword(event.target.value)}/></label><label className="mt-4 block text-xs text-black/45"><input type="datetime-local" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)}/></label><button onClick={async () => { try { const updated = await api.updateCustomerAccess(id, { enabled: accessEnabled, password: accessPassword || undefined, expires_at: expiresAt || null }); setProject(updated); setMessage('客户访问设置已保存'); setAccessPassword(''); } catch (error) { setMessage(error instanceof Error ? error.message : '保存失败'); } }} className="mt-5 w-full rounded-full bg-black py-3 text-sm text-white"></button>{message && <p className="mt-3 text-center text-xs text-black/50">{message}</p>}</Modal>}
</main>;
}