fix(ui): 兼容 HTTP 复制并固化开发启停
This commit is contained in:
@@ -31,5 +31,3 @@ temp
|
||||
.idea
|
||||
.trae
|
||||
.vercel
|
||||
atelier-notes.zip
|
||||
=
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -65,7 +65,3 @@ Thumbs.db
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Local source archives and accidental files
|
||||
/atelier-notes.zip
|
||||
/=
|
||||
|
||||
@@ -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 之外。
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
- 已上传作品在所有阶段的图片重新排序
|
||||
- 在线人员状态、实时变更通知和并发冲突保护
|
||||
- HEIC/HEIF 转换、多尺寸缩略图和 EXIF 定位信息清理
|
||||
- 真实腾讯云、生产 PostgreSQL、HTTPS 和备份恢复演练
|
||||
- 独立生产 COS 桶、生产 PostgreSQL、HTTPS 和备份恢复演练
|
||||
|
||||
## 上线门槛
|
||||
|
||||
|
||||
@@ -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 维护
|
||||
|
||||
|
||||
@@ -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
111
scripts/dev.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ManagedUser | null>(null);
|
||||
const [renameTarget, setRenameTarget] = useState<ManagedUser | null>(null);
|
||||
const [replaceGroup, setReplaceGroup] = useState<OperationGroup | null>(null);
|
||||
@@ -108,7 +110,7 @@ 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 === 'keys' && <ApiKeys items={keys} onCreate={() => { setRevealedKey(''); setCopyState('idle'); setPanel('key'); }} onRevoke={(item) => void run(() => api.revokeApiKey(item.id))}/>}
|
||||
{tab === 'storage' && isPlatform && <StorageSettings/>}
|
||||
{tab === 'audit' && <AuditLogs items={logs} filteredUser={auditUserId ? accounts.find((item)=>item.id===auditUserId) : undefined} onClear={() => setAuditUserId(null)}/>}
|
||||
</>}
|
||||
@@ -124,7 +126,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="2–40 个字符"><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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user