Compare commits

...

5 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
yuzhe
4569724cec docs: 同步 Agent 上传与优化接口说明 2026-07-23 13:55:00 +08:00
yuzhe
557e24883d feat(skill): 固定服务地址并强化目标确认 2026-07-23 13:50:24 +08:00
20 changed files with 211 additions and 42 deletions

View File

@@ -12,6 +12,7 @@ Use a two-phase plan/apply workflow. Optimize for correct placement, not speed.
- Treat the product hierarchy as `operation group -> project -> work -> review round`.
- Never use a collection/delivery-set identifier. `collections`, `notes`, and `versions` are legacy compatibility names.
- Never infer a project or work from a partial name, page position, recent activity, or a remembered ID.
- At the start of every invocation, discover and explicitly ask the operator to confirm the exact operation group and project. A target confirmation from an earlier invocation cannot be reused.
- Never create a work until the operator confirms the resolved group, project, content, image order, and confirmation code.
- Never create a new round until the operator confirms the resolved work and current round.
- Never retry a timed-out create request. First inspect current server state; otherwise a retry can create a duplicate round.
@@ -38,7 +39,7 @@ Do not create groups, projects, API keys, feedback, or review decisions with thi
For both operations, require:
- Delivery Desk base URL. Default to `DELIVERY_DESK_BASE_URL` or `http://127.0.0.1:3010` only for local development.
- Use the fixed Delivery Desk API address `http://192.168.30.90:3010`. Do not ask the operator to configure it. Use `--base-url` only when the operator explicitly instructs you to migrate or test another environment.
- A valid API key in `DELIVERY_DESK_API_KEY`.
- Exact target project, resolved to group ID/name and project ID/name/slug.
- Ordered public image URLs or ordered local image paths.
@@ -57,7 +58,7 @@ For `create_round`, also require:
When information is missing, ask one concise question listing only the missing fields. Do not proceed to mutation.
## 3. Discover authoritative IDs
## 3. Discover and confirm the authoritative target
Use the bundled script from the skill directory:
@@ -73,8 +74,9 @@ Resolution rules:
1. Match IDs first.
2. Validate the project ID against its returned group ID, group name, project name, and slug.
3. If the operator supplied only names, list exact matches with IDs and ask the operator to choose when zero or multiple matches exist.
4. Even with one match, show the resolved identity before creating the plan.
5. For a new round, verify the work belongs to the confirmed project.
4. Even when the request already names a target or only one project is accessible, show the resolved group name/ID and project name/ID/slug and ask: `本次操作目标是否为:运营组「<group_name>」(ID <group_id>) / 项目「<project_name>」(ID <project_id>, slug <slug>)?请回复“确认目标”。`
5. Accept only an explicit target confirmation given during the current invocation. Do not generate `plan-work` or `plan-round` before it.
6. For a new round, verify the work belongs to the confirmed project.
Do not silently choose the only project merely because an API key currently exposes one.

View File

@@ -1,4 +1,4 @@
interface:
display_name: "上传 Delivery Desk 作品"
short_description: "精确定位运营组与项目,安全创建作品或提交新的验收轮次"
default_prompt: "Use $upload-delivery-desk-work to safely locate the exact project and upload a work or a new review round."
default_prompt: "Use $upload-delivery-desk-work to discover and ask me to confirm the exact operation group and project before uploading a work or a new review round."

View File

@@ -19,6 +19,7 @@ from urllib.parse import quote, urlsplit
from urllib.request import Request, urlopen
MAX_LOCAL_IMAGE_BYTES = 20 * 1024 * 1024
DEFAULT_BASE_URL = "http://192.168.30.90:3010"
LOCAL_IMAGE_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
@@ -75,7 +76,7 @@ def api_key() -> str:
def base_url(value: str | None = None) -> str:
return (value or os.getenv("DELIVERY_DESK_BASE_URL") or "http://127.0.0.1:3010").rstrip("/")
return (value or DEFAULT_BASE_URL).rstrip("/")
def request_json(base: str, path: str, *, method: str = "GET", body: dict[str, Any] | None = None) -> tuple[int, Any]:
@@ -461,7 +462,7 @@ def cmd_apply(args: argparse.Namespace) -> None:
def add_common(command: argparse.ArgumentParser) -> None:
command.add_argument("--base-url", default=None)
command.add_argument("--base-url", default=None, help=f"Delivery Desk API 地址(默认:{DEFAULT_BASE_URL}")
def add_content(command: argparse.ArgumentParser, *, title_required: bool) -> None:

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

@@ -67,10 +67,11 @@ flowchart LR
- 点击图片打开悬浮图片窗格;只有该窗格可以新增坐标批注,并支持原图查看、缩放和前后切换。点击窗格外会同时关闭图片窗格和验收协作面板。
- 标题、正文和 Tag 批注保存 `start_offset``end_offset``selected_text` 及前后文,提交时校验选区仍与轮次快照一致。
- `GET /api/works/:workId/annotations` 按轮次返回图片批注、文字批注、总体反馈和验收事件。
- `GET /api/works/:workId/optimization-context?round=N` 聚合指定轮次的内容、图片和可执行反馈,默认排除已关闭或撤回记录,供外部内容优化流程只读使用。
## Agent 安全上传
内置 Agent Skill 采用“发现目标 → 生成计划 → 人工确认 → 单次写入 → 读取核验”的两阶段流程。计划文件只保存目标 ID、待写内容和确认摘要不保存 API Key并写入已被 Git 忽略的 `tmp/` 目录。
内置 Agent Skill 固定连接 `http://192.168.30.90:3010`,采用“发现目标 → 操作者确认运营组和项目 → 生成计划 → 操作者确认内容与确认 → 单次写入 → 读取核验”的两阶段确认流程。计划文件只保存目标 ID、待写内容和确认摘要不保存 API Key并写入已被 Git 忽略的 `tmp/` 目录。
新建作品以 `externalId` 保证幂等;新增验收轮次没有幂等键。轮次写入超时或响应不明确时,必须先重新读取作品状态,不能直接重试,以免重复创建轮次。

View File

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

View File

@@ -12,7 +12,7 @@ Authorization: Bearer dd_live_xxx
## Agent 安全上传 Skill
项目内置 `.agents/skills/upload-delivery-desk-work`,用于引导 Agent 精确定位运营组、项目和作品后创建作品或提交新验收轮次。它强制执行“发现 → 生成计划 → 操作者确认 → 单次提交 → 回读验证”,不允许根据名称猜测目标。
项目内置 `.agents/skills/upload-delivery-desk-work`,用于引导 Agent 精确定位运营组、项目和作品后创建作品或提交新验收轮次。它固定连接 `http://192.168.30.90:3010`,并强制执行“发现 → 操作者确认运营组与项目 → 生成计划 → 操作者确认内容 → 单次提交 → 回读验证”,不允许根据名称猜测目标。
更新 Skill 后重新生成分发包:
@@ -113,8 +113,14 @@ curl "http://localhost:3010/api/works/12?round=2" \
# 按轮返回该作品全部反馈和验收事件
curl http://localhost:3010/api/works/12/annotations \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
# 返回用于优化指定轮次内容与图片的精简上下文;默认只含未撤回的开放反馈
curl "http://localhost:3010/api/works/12/optimization-context?round=2" \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
```
`optimization-context` 返回项目、作品、轮次、标题、正文、Tag、图片元信息以及图片批注、文字批注、总体反馈和回复。需要审计已关闭或已撤回的历史反馈时增加 `include_history=true`;该接口只读,不会修改作品。
标题、正文和 Tag 选区批注使用:
```json

View File

@@ -51,12 +51,14 @@ 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 维护
Agent 通过 API 新建作品或提交验收轮次时,使用 `.agents/skills/upload-delivery-desk-work`。API Key 只通过 `DELIVERY_DESK_API_KEY` 环境变量注入,不写入计划文件、文档或 Git。
Skill 默认固定连接 `http://192.168.30.90:3010`,不读取环境变量覆盖服务地址。只有操作者明确要求迁移或测试其他环境时,才使用 `--base-url` 指定另一地址。
Agent 可用 `--image-url` 提交公网图片,也可用 `--image-file` 将生成在运营电脑上的本地图片直接 multipart 上传;两种模式不混用,不把图片转换为 Base64。
```powershell

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

View File

@@ -70,6 +70,21 @@ def apply_silently(path: Path, code: str) -> dict:
def main() -> None:
previous_base_url = os.environ.get("DELIVERY_DESK_BASE_URL")
os.environ["DELIVERY_DESK_BASE_URL"] = "http://should-not-override.invalid"
try:
assert MODULE.base_url() == "http://192.168.30.90:3010"
assert MODULE.base_url("https://delivery.example.com/") == "https://delivery.example.com"
finally:
if previous_base_url is None:
os.environ.pop("DELIVERY_DESK_BASE_URL", None)
else:
os.environ["DELIVERY_DESK_BASE_URL"] = previous_base_url
skill_text = (SCRIPT.parents[1] / "SKILL.md").read_text(encoding="utf-8")
assert "请回复“确认目标”" in skill_text
assert "Do not generate `plan-work` or `plan-round` before it." in skill_text
original_exact_project = MODULE.exact_project
original_request_json = MODULE.request_json
original_request_multipart = MODULE.request_multipart