Compare commits
3 Commits
4569724cec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
850f213aa5 | ||
|
|
fd93c69245 | ||
|
|
2dadeed759 |
@@ -31,5 +31,3 @@ temp
|
|||||||
.idea
|
.idea
|
||||||
.trae
|
.trae
|
||||||
.vercel
|
.vercel
|
||||||
atelier-notes.zip
|
|
||||||
=
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ NODE_ENV=development
|
|||||||
CORS_ORIGIN=http://localhost:5180
|
CORS_ORIGIN=http://localhost:5180
|
||||||
|
|
||||||
# 正式环境必须填写;本地 SQLite 模式可暂时留空。
|
# 正式环境必须填写;本地 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
|
PGSSL=disable
|
||||||
PG_POOL_MAX=10
|
PG_POOL_MAX=10
|
||||||
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -65,7 +65,3 @@ Thumbs.db
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
# Local source archives and accidental files
|
|
||||||
/atelier-notes.zip
|
|
||||||
/=
|
|
||||||
|
|||||||
@@ -5,6 +5,6 @@
|
|||||||
- 新接口使用 `/api/projects/:projectId/works` 和 `/api/works/:workId/rounds`;`notes`、`collections`、`versions` 路由只做一个兼容周期,不再扩展。
|
- 新接口使用 `/api/projects/:projectId/works` 和 `/api/works/:workId/rounds`;`notes`、`collections`、`versions` 路由只做一个兼容周期,不再扩展。
|
||||||
- 数据库结构变更必须同时更新 `api/db.ts`、`db/postgres/schema.sql` 和迁移验证。
|
- 数据库结构变更必须同时更新 `api/db.ts`、`db/postgres/schema.sql` 和迁移验证。
|
||||||
- `data/`、`uploads/`、`.env*`、COS 凭证、数据库文件及用户上传内容不得提交。
|
- `data/`、`uploads/`、`.env*`、COS 凭证、数据库文件及用户上传内容不得提交。
|
||||||
- 本地开发可使用 SQLite;正式部署使用 PostgreSQL。COS 配置只通过平台管理界面或部署密钥注入。
|
- 本地开发可使用 SQLite;正式部署使用 PostgreSQL。COS 凭证只通过平台管理界面录入;`COS_CONFIG_ENCRYPTION_KEY` 通过部署密钥注入。
|
||||||
- 不把 `.trae/` 中的早期原型文档作为现行依据;以 README、`docs/` 和当前代码为准。
|
- 不把 `.trae/` 中的早期原型文档作为现行依据;以 README、`docs/` 和当前代码为准。
|
||||||
- Agent 通过 API 上传作品或创建验收轮次时必须使用 `.agents/skills/upload-delivery-desk-work`;接口或层级变化后同步更新该 Skill,并运行 `powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1` 重新打包。
|
- Agent 通过 API 上传作品或创建验收轮次时必须使用 `.agents/skills/upload-delivery-desk-work`;接口或层级变化后同步更新该 Skill,并运行 `powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1` 重新打包。
|
||||||
|
|||||||
@@ -22,16 +22,19 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm dev
|
pnpm dev:start
|
||||||
```
|
```
|
||||||
|
|
||||||
- 前端:http://localhost:5180
|
- 前端:http://localhost:5180
|
||||||
- API:http://localhost:3010
|
- 后端:http://localhost:3010
|
||||||
|
- 重启:`pnpm dev:restart`
|
||||||
|
- 停止:`pnpm dev:stop`
|
||||||
|
- 查看状态:`pnpm dev:status`
|
||||||
- 健康检查:http://localhost:3010/api/health
|
- 健康检查:http://localhost:3010/api/health
|
||||||
|
|
||||||
未配置 `DATABASE_URL` 时使用 `data/app.db`;本地上传文件保存在 `uploads/`。两者均包含运行数据或用户文件,已排除在 Git 之外。
|
未配置 `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
|
## PostgreSQL 与 Docker
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
- 已上传作品在所有阶段的图片重新排序
|
- 已上传作品在所有阶段的图片重新排序
|
||||||
- 在线人员状态、实时变更通知和并发冲突保护
|
- 在线人员状态、实时变更通知和并发冲突保护
|
||||||
- HEIC/HEIF 转换、多尺寸缩略图和 EXIF 定位信息清理
|
- 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 的文件,因此部署网络必须允许访问确需导入的公开图片源。
|
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 维护
|
## Agent 上传 Skill 维护
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
"test:postgres-runtime": "tsx scripts/test-postgres-runtime.ts",
|
"test:postgres-runtime": "tsx scripts/test-postgres-runtime.ts",
|
||||||
"test:collection-status": "tsx scripts/test-sqlite-collection-status.ts",
|
"test:collection-status": "tsx scripts/test-sqlite-collection-status.ts",
|
||||||
"test:review-rounds": "tsx scripts/test-sqlite-review-rounds.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\""
|
"dev": "concurrently \"npm run client:dev\" \"npm run server:dev\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { CheckCircle2, Cloud, Database, Loader2, LockKeyhole, RadioTower, ShieldCheck } from 'lucide-react';
|
||||||
import type { StorageConfig } from '@shared/types';
|
import type { StorageConfig } from '@shared/types';
|
||||||
import { api, ApiError } from '@/api/client';
|
import { api, ApiError } from '@/api/client';
|
||||||
@@ -8,10 +8,8 @@ const initialForm = {
|
|||||||
path_prefix: 'delivery-desk', secret_id: '', secret_key: '',
|
path_prefix: 'delivery-desk', secret_id: '', secret_key: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StorageSettings() {
|
export default function StorageSettings({ items, onItemsChange }: { items: StorageConfig[]; onItemsChange: (items: StorageConfig[]) => void }) {
|
||||||
const [items, setItems] = useState<StorageConfig[]>([]);
|
|
||||||
const [form, setForm] = useState(initialForm);
|
const [form, setForm] = useState(initialForm);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [busy, setBusy] = useState<'save' | 'test' | 'activate' | null>(null);
|
const [busy, setBusy] = useState<'save' | 'test' | 'activate' | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [notice, setNotice] = useState('');
|
const [notice, setNotice] = useState('');
|
||||||
@@ -19,12 +17,9 @@ export default function StorageSettings() {
|
|||||||
const draft = useMemo(() => items.find((item) => item.status === 'draft'), [items]);
|
const draft = useMemo(() => items.find((item) => item.status === 'draft'), [items]);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
try { onItemsChange(await api.listStorageConfigs()); }
|
||||||
try { setItems(await api.listStorageConfigs()); }
|
|
||||||
catch (reason) { setError(messageOf(reason)); }
|
catch (reason) { setError(messageOf(reason)); }
|
||||||
finally { setLoading(false); }
|
|
||||||
};
|
};
|
||||||
useEffect(() => { void load(); }, []);
|
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setBusy('save'); setError(''); setNotice('');
|
setBusy('save'); setError(''); setNotice('');
|
||||||
@@ -55,8 +50,6 @@ export default function StorageSettings() {
|
|||||||
finally { setBusy(null); }
|
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">
|
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>
|
<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>}
|
{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>}
|
||||||
|
|||||||
@@ -4,3 +4,33 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ArrowRightLeft, ChevronDown, Copy, Fingerprint, History, KeyRound, Plus, RefreshCw, Search, ShieldCheck, Users, X } from 'lucide-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 { api, ApiError } from '@/api/client';
|
||||||
import { useAuthStore } from '@/store/useAuthStore';
|
import { useAuthStore } from '@/store/useAuthStore';
|
||||||
import StorageSettings from '@/components/StorageSettings';
|
import StorageSettings from '@/components/StorageSettings';
|
||||||
|
import { copyText } from '@/lib/utils';
|
||||||
|
|
||||||
type Tab = 'groups' | 'accounts' | 'keys' | 'storage' | 'audit';
|
type Tab = 'groups' | 'accounts' | 'keys' | 'storage' | 'audit';
|
||||||
type Panel = 'group' | 'user' | 'key' | 'rename' | 'rename_group' | 'replace_admin' | 'reset' | null;
|
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 [keys, setKeys] = useState<ManagedApiKey[]>([]);
|
||||||
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
|
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [storageConfigs, setStorageConfigs] = useState<StorageConfig[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [revealedKey, setRevealedKey] = useState('');
|
const [revealedKey, setRevealedKey] = useState('');
|
||||||
|
const [copyState, setCopyState] = useState<'idle' | 'copying' | 'success' | 'error'>('idle');
|
||||||
const [resetTarget, setResetTarget] = useState<ManagedUser | null>(null);
|
const [resetTarget, setResetTarget] = useState<ManagedUser | null>(null);
|
||||||
const [renameTarget, setRenameTarget] = useState<ManagedUser | null>(null);
|
const [renameTarget, setRenameTarget] = useState<ManagedUser | null>(null);
|
||||||
const [replaceGroup, setReplaceGroup] = useState<OperationGroup | null>(null);
|
const [replaceGroup, setReplaceGroup] = useState<OperationGroup | null>(null);
|
||||||
@@ -51,11 +54,12 @@ export default function ManagementPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
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(),
|
api.listManagedUsers(), api.listApiKeys(), api.listAuditLogs(), api.listProjects(),
|
||||||
isPlatform ? api.listGroups() : Promise.resolve([]),
|
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)); }
|
} catch (reason) { setError(messageOf(reason)); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
}, [isPlatform, user]);
|
}, [isPlatform, user]);
|
||||||
@@ -65,9 +69,9 @@ export default function ManagementPage() {
|
|||||||
...(isPlatform ? [{ id: 'groups' as const, label: '运营组', count: groups.length }] : []),
|
...(isPlatform ? [{ id: 'groups' as const, label: '运营组', count: groups.length }] : []),
|
||||||
{ id: 'accounts' as const, label: '账号', count: accounts.length },
|
{ id: 'accounts' as const, label: '账号', count: accounts.length },
|
||||||
{ id: 'keys' as const, label: 'API Key', count: keys.filter((item) => item.status === 'active').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 },
|
{ 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') {
|
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>;
|
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 /> : <>
|
{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 === '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 === '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 === 'storage' && isPlatform && <StorageSettings items={storageConfigs} onItemsChange={setStorageConfigs}/>}
|
||||||
{tab === 'audit' && <AuditLogs items={logs} filteredUser={auditUserId ? accounts.find((item)=>item.id===auditUserId) : undefined} onClear={() => setAuditUserId(null)}/>}
|
{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 === '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 === '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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ArrowLeft, Copy, ImageIcon, KeyRound, MessageCircle, Pencil, Plus, Sear
|
|||||||
import type { Note, Project, ReviewStatus } from '@shared/types';
|
import type { Note, Project, ReviewStatus } from '@shared/types';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import StatusBadge from '@/components/StatusBadge';
|
import StatusBadge from '@/components/StatusBadge';
|
||||||
|
import { copyText } from '@/lib/utils';
|
||||||
|
|
||||||
const projectStatus = { draft: '待提交', reviewing: '验收中', completed: '验收完毕', archived: '已归档' } as const;
|
const projectStatus = { draft: '待提交', reviewing: '验收中', completed: '验收完毕', archived: '已归档' } as const;
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export default function ProjectPage() {
|
|||||||
const [accessPassword, setAccessPassword] = useState('');
|
const [accessPassword, setAccessPassword] = useState('');
|
||||||
const [expiresAt, setExpiresAt] = useState('');
|
const [expiresAt, setExpiresAt] = useState('');
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
const [copyState, setCopyState] = useState<'idle' | 'copying' | 'success' | 'error'>('idle');
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const [nextProject, nextWorks] = await Promise.all([api.getProject(id), api.listProjectWorks(id)]);
|
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>;
|
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 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>
|
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>
|
<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>
|
</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>}
|
{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>;
|
</main>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user