Compare commits

...

6 Commits

Author SHA1 Message Date
yuzhe
69cfd0b51d fix(storage): 强化外部图片导入与 Skill 异常引导 2026-07-22 19:34:36 +08:00
yuzhe
3bfb481c71 fix(skill): 修正上传 Skill 安装包结构 2026-07-22 18:31:27 +08:00
yuzhe
b8b4d7a11c feat(api): 添加安全上传 Skill 和 COS 图片归一化 2026-07-22 18:12:23 +08:00
yuzhe
e7e268d4eb feat(review): 重构项目级单方案验收协作 2026-07-22 16:41:03 +08:00
yuzhe
6091d61612 feat(review): 支持多候选稿验收轮次
- 支持每轮提交 1–5 个候选稿并按指定稿验收
- 保留历史轮次只读并兼容单候选稿版本接口
- 同步 SQLite/PostgreSQL schema、迁移验证、测试与项目文档
2026-07-21 20:25:52 +08:00
yuzhe
721e971dd8 feat(collections): 自动同步作品交付集验收状态 2026-07-21 19:23:58 +08:00
58 changed files with 3471 additions and 617 deletions

View File

@@ -0,0 +1,164 @@
---
name: upload-delivery-desk-work
description: Safely locate the exact Delivery Desk operation group and project, then create a work or submit one new review round through the current API. Use for requests such as 上传作品、新增作品、提交新一轮、更新作品, or any agent upload where group/project/work identity may be ambiguous and a wrong target must be prevented.
---
# Upload Delivery Desk Work
Use a two-phase plan/apply workflow. Optimize for correct placement, not speed.
## Non-negotiable rules
- 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.
- 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.
- Require `externalId` for every agent-created work. Reuse the same value for safe retries.
- Accept 1-30 public `http`/`https` image URLs. Preserve their order; the first image is the cover.
- Require an active Tencent COS configuration. URLs already using its public or CDN origin are reused; other public images are downloaded and stored in that COS by the API.
- Put API keys only in `DELIVERY_DESK_API_KEY`. Do not paste keys into chat, plans, source files, or command history.
- Prefer a project-scoped API key. A platform key has a wider blast radius and always requires explicit group verification.
- Stop on any mismatch, ambiguity, changed project/work state, or missing input. Ask the operator instead of guessing.
## 1. Classify the operation
Determine exactly one operation:
- `create_work`: create a new work in a project.
- `create_round`: submit the next review round for an existing work.
If the request says “update”, “new version”, or “upload again” without identifying whether it is a new work or a new round, ask which operation is intended.
Do not create groups, projects, API keys, feedback, or review decisions with this skill.
## 2. Collect required information
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.
- 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.
- Confirmation that the source URLs are reachable until the API finishes importing them. After a cross-origin import succeeds, Delivery Desk uses the resulting COS URL.
For `create_work`, also require:
- A stable caller-generated `externalId` matching `[A-Za-z0-9._:-]{1,128}`.
- Title. Description and tags may be empty only when the operator explicitly intends that.
For `create_round`, also require:
- Work ID. If only an `externalId` is known, discover the work inside the confirmed project first.
- Whether title, description, and tags should be replaced or retained from the current round. Omitted values are retained by the planning script and must be visible in the confirmation summary.
When information is missing, ask one concise question listing only the missing fields. Do not proceed to mutation.
## 3. Discover authoritative IDs
Use the bundled script from the skill directory:
```powershell
$skillScript = ".agents/skills/upload-delivery-desk-work/scripts/delivery_desk_upload.py"
python $skillScript projects
python $skillScript works --project-id 12
python $skillScript inspect-work --project-id 12 --work-id 34
```
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.
Do not silently choose the only project merely because an API key currently exposes one.
## 4. Generate a read-only plan
Create a new work plan:
```powershell
python $skillScript plan-work `
--project-id 12 `
--external-id client-2026-001 `
--title "作品标题" `
--description "正文" `
--tag "#夏日" `
--image-url "https://cdn.example.com/01.jpg" `
--image-url "https://cdn.example.com/02.jpg" `
--output tmp/delivery-plan.json
```
Create a new round plan:
```powershell
python $skillScript plan-round `
--project-id 12 `
--work-id 34 `
--image-url "https://cdn.example.com/round-2-01.jpg" `
--output tmp/delivery-plan.json
```
Optional `plan-round` content flags:
- `--title`, `--description`, and repeated `--tag` replace current values.
- `--clear-description` or `--clear-tags` intentionally clear those fields.
- If omitted, the current round value is retained.
The plan contains no credentials and performs no mutation.
## 5. Obtain explicit confirmation
Show the plan summary exactly, including:
- Operation.
- Group name and ID.
- Project name, ID, and slug.
- Work ID and current/next round for `create_round`.
- `externalId` for `create_work`.
- Title, complete description, complete tag list.
- Ordered image URLs with `1` marked as the cover.
- Confirmation code.
Ask: `确认按以上目标和内容执行吗?请回复“确认 <confirmation_code>”。`
Accept only an explicit confirmation containing the current code. Any content or target change invalidates the old plan; regenerate it and ask again.
## 6. Apply and verify
After exact confirmation:
```powershell
python $skillScript apply `
--plan tmp/delivery-plan.json `
--confirm ABCD1234EF56
```
The script re-fetches the project/work, checks for state drift, performs one POST, and reads the created resource back. Treat only a successful verification result as complete.
Report group, project, work ID, `externalId`, created round number, title, image count, and whether the server returned an existing idempotent work.
If the result is `state_unknown`, do not rerun `apply`. Run `inspect-work` and compare server state to the plan. If the result still cannot be proven, ask the operator before any retry.
## Failure actions
On failure, the script exits with code `1` and writes one JSON object to stderr:
```json
{"success": false, "action": "revise", "error": "...", "next_step": "..."}
```
Follow `action` exactly:
- `retry`: Retry only the same read-only command, with the same target and parameters, after completing `next_step`. Never reinterpret this as permission to retry `apply`.
- `revise`: Change only the invalid input identified by `error`, regenerate the plan, show the full changed plan, and obtain a new confirmation code before `apply`.
- `ask_operator`: Stop. Show the exact target, `error`, and `next_step` to the operator or administrator. Do not change targets, permissions, project state, or credentials yourself.
If output is `state_unknown`, it is always `ask_operator`: inspect current server state first and never automatically repeat the write.
## API troubleshooting
Read [references/api-contract.md](references/api-contract.md) before calling an endpoint directly, interpreting an error, or changing this skill for a new API version.

View File

@@ -0,0 +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."

View File

@@ -0,0 +1,108 @@
# Delivery Desk upload contract
## Current hierarchy and identity
`operation group -> project -> work -> review round`
- A project belongs to exactly one operation group.
- A work belongs to exactly one project.
- A review round belongs to exactly one work and contains exactly one proposal.
- `externalId` is unique within a project and is the only supported idempotency key for agent-created works.
- Product-facing operations do not accept a collection/delivery-set ID.
## Authentication and scope
Send `Authorization: Bearer <DELIVERY_DESK_API_KEY>`.
| Key scope | Readable projects | Create work | Create round |
|---|---|---|---|
| `project` | Its bound active project | Only in that project | Only for works in that project |
| `platform` | All non-archived projects | Any active project | Any work in an active project |
Prefer `project`. Do not store the token in a plan file.
## Discovery endpoints
### `GET /api/projects`
Returns accessible projects. Relevant fields:
```json
{
"id": 12,
"group_id": 3,
"group_name": "示例运营组",
"name": "光影内容计划",
"slug": "light-notes",
"status": "active",
"review_status": "reviewing"
}
```
### `GET /api/projects/:projectId/works`
Returns works in the exact project. Use `?externalId=<value>` for exact external-ID lookup.
### `GET /api/works/:workId`
Returns current work content, project identity, images, and rounds. Use `?round=N` only when inspecting a historical round.
## Mutation endpoints
### `POST /api/projects/:projectId/works`
JSON body:
```json
{
"externalId": "client-2026-001",
"title": "作品标题",
"description": "正文",
"tags": ["#夏日"],
"images": ["https://cdn.example.com/01.jpg"]
}
```
Constraints:
- `title` is required.
- `images` contains 1-30 public HTTP/HTTPS URLs.
- Array order is display order; item 1 is the cover.
- An active Tencent COS configuration is required. URLs on its configured public/CDN origin are reused; other public images are downloaded, validated, and stored in that COS before the work is created.
- Cross-origin images must be supported image responses no larger than 20 MB. Local, private, reserved, and non-standard-port targets are rejected.
- Repeating the same `projectId + externalId` returns the existing work with `idempotent: true`.
### `POST /api/works/:workId/rounds`
JSON body:
```json
{
"title": "修改后的标题",
"description": "修改后的正文",
"tags": ["#第二轮"],
"images": ["https://cdn.example.com/round-2.jpg"]
}
```
This endpoint is not idempotent. One successful call creates exactly one new round. Never blindly retry after a timeout.
It applies the same COS reuse/import rules as work creation.
## State guards
- Only `active` projects accept new works or rounds.
- Closed or archived projects are read-only.
- A completed project may require an authorized administrator to reopen the relevant workflow before another round can be created.
| Status | Script action | Required next step |
|---|---|---|
| `400` | `revise` | Correct malformed input, regenerate the plan, and obtain a new confirmation. |
| `401` | `ask_operator` | Ask the operator to configure or replace `DELIVERY_DESK_API_KEY`. |
| `403` | `ask_operator` | Report the exact group/project and ask an administrator to correct Key scope. Never select another target. |
| `404` | `ask_operator` | Re-run read-only discovery, then ask the operator if the confirmed target is gone or inaccessible. |
| `409` | `ask_operator` | Report the target state and wait for the operator or administrator to resolve it. |
| `413` | `revise` | Replace or reduce the image, regenerate the plan, and obtain a new confirmation. |
| `422` | `revise` | Replace the unreachable or unsupported public image URL, regenerate the plan, and obtain a new confirmation. |
| `502` | `ask_operator` for writes | Do not retry the write automatically; inspect state and ask the operator to check Tencent COS. |
| `429`, `503`, `504` | `retry` for reads only | Wait and retry the same read-only command without changing the target. Writes require `ask_operator`. |
Do not work around `403` or `409` by selecting a different project. Report the exact target and ask the operator or administrator to resolve access/state.

View File

@@ -0,0 +1,371 @@
#!/usr/bin/env python3
"""Two-phase, target-safe Delivery Desk work uploader."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
class UploadError(RuntimeError):
def __init__(self, message: str, *, action: str = "ask_operator", next_step: str = "停止操作,将错误和目标信息告知调用者并等待处理") -> None:
super().__init__(message)
self.action = action
self.next_step = next_step
def payload(self) -> dict[str, Any]:
return {"success": False, "action": self.action, "error": str(self), "next_step": self.next_step}
def revise(message: str, next_step: str) -> UploadError:
return UploadError(message, action="revise", next_step=next_step)
def retry(message: str, next_step: str) -> UploadError:
return UploadError(message, action="retry", next_step=next_step)
def http_failure(method: str, path: str, status: int, detail: str) -> UploadError:
message = f"{method} {path} 返回 {status}: {detail}"
if status in {400, 413, 422}:
return revise(message, "根据错误修改输入或图片 URL重新生成计划并取得新的确认码后再执行")
if status == 401:
return UploadError(message, next_step="停止操作,请调用者配置或更换有效的 DELIVERY_DESK_API_KEY")
if status == 403:
return UploadError(message, next_step="停止操作,向调用者报告已确认的运营组和项目,请管理员修正 Key 权限;不要改选其他项目")
if status == 404:
return UploadError(message, next_step="重新执行只读发现命令核对目标;若目标已删除或不可见,询问调用者,不要猜测新目标")
if status == 409:
return UploadError(message, next_step="停止操作并报告目标当前状态,请调用者或管理员解除冲突;不要更换目标或自动重试")
if method == "GET" and status in {429, 502, 503, 504}:
return retry(message, "等待片刻后原样重试当前只读命令;不要改变目标或参数")
if method == "POST":
return UploadError(message, next_step="写入结果可能不确定,禁止自动重试;先只读检查目标状态,再询问调用者")
return UploadError(message, next_step="停止操作并向调用者报告服务异常;确认服务恢复后再重新执行只读发现")
def api_key() -> str:
value = os.getenv("DELIVERY_DESK_API_KEY", "").strip()
if not value:
raise UploadError("缺少 DELIVERY_DESK_API_KEY请由操作者在环境变量中配置不要粘贴到计划文件")
return value
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("/")
def request_json(base: str, path: str, *, method: str = "GET", body: dict[str, Any] | None = None) -> tuple[int, Any]:
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
headers = {"Authorization": f"Bearer {api_key()}", "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
request = Request(f"{base}{path}", data=data, method=method, headers=headers)
try:
with urlopen(request, timeout=20) as response:
raw = response.read().decode("utf-8")
return response.status, json.loads(raw) if raw else None
except HTTPError as error:
raw = error.read().decode("utf-8", errors="replace")
try:
payload = json.loads(raw)
detail = payload.get("error", raw) if isinstance(payload, dict) else raw
except json.JSONDecodeError:
detail = raw
raise http_failure(method, path, error.code, str(detail)) from error
except URLError as error:
if method == "POST":
raise UploadError(
f"state_unknown: {method} {path} 的结果未知,禁止自动重试;请先检查服务器状态:{error.reason}",
next_step="使用 works --external-id 或 inspect-work 只读核对服务器状态;若仍无法确认,询问调用者后再决定",
) from error
raise retry(f"无法连接 {base}: {error.reason}", "确认服务地址正确且服务可达后,原样重试当前只读命令") from error
def print_json(value: Any) -> None:
print(json.dumps(value, ensure_ascii=False, indent=2))
def projects(base: str) -> list[dict[str, Any]]:
_, value = request_json(base, "/api/projects")
if not isinstance(value, list):
raise UploadError("项目发现接口返回格式无效")
return value
def exact_project(base: str, project_id: int) -> dict[str, Any]:
available = projects(base)
matches = [item for item in available if int(item.get("id", 0)) == project_id]
if len(matches) != 1:
choices = [{"group_id": p.get("group_id"), "group_name": p.get("group_name"), "project_id": p.get("id"), "project_name": p.get("name"), "slug": p.get("slug")} for p in available]
raise UploadError(f"项目 ID {project_id} 不存在或当前 Key 无权访问。可访问项目:{json.dumps(choices, ensure_ascii=False)}")
return matches[0]
def project_identity(project: dict[str, Any]) -> dict[str, Any]:
return {
"group_id": int(project["group_id"]),
"group_name": str(project["group_name"]),
"project_id": int(project["id"]),
"project_name": str(project["name"]),
"project_slug": str(project["slug"]),
"project_status": str(project["status"]),
}
def validate_images(values: list[str]) -> list[str]:
if not 1 <= len(values) <= 30:
raise revise("必须提供 1-30 个图片 URL", "调整图片数量和顺序,重新生成计划并取得新的确认码")
cleaned = [value.strip() for value in values]
if any(not re.match(r"^https?://[^\s]+$", value, re.IGNORECASE) or len(value) > 2048 for value in cleaned):
raise revise("图片必须是长度不超过 2048 的公开 HTTP/HTTPS URL", "替换为可公开访问的 HTTP/HTTPS 图片 URL重新生成计划并取得新的确认码")
return cleaned
def confirmation_code(plan: dict[str, Any]) -> str:
material = {key: value for key, value in plan.items() if key != "confirmation_code"}
canonical = json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12].upper()
def save_plan(plan: dict[str, Any], output: str) -> None:
plan["confirmation_code"] = confirmation_code(plan)
destination = Path(output)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print_json({"plan_file": str(destination), "plan": plan})
def get_work(base: str, work_id: int) -> dict[str, Any]:
_, value = request_json(base, f"/api/works/{work_id}")
if not isinstance(value, dict):
raise UploadError("作品详情接口返回格式无效")
return value
def assert_work_project(work: dict[str, Any], project_id: int) -> None:
actual = int((work.get("project") or {}).get("id", 0))
if actual != project_id:
raise UploadError(f"作品属于项目 {actual},不是已确认项目 {project_id}")
def content_from_args(args: argparse.Namespace, current: dict[str, Any] | None = None) -> dict[str, Any]:
current = current or {}
if args.clear_description and args.description is not None:
raise revise("--description 与 --clear-description 不能同时使用", "只保留其中一个参数后重新生成计划")
if args.clear_tags and args.tags is not None:
raise revise("--tag 与 --clear-tags 不能同时使用", "只保留其中一种 Tag 操作后重新生成计划")
title = args.title if args.title is not None else current.get("title")
description = "" if args.clear_description else args.description if args.description is not None else current.get("description", "")
tags = [] if args.clear_tags else args.tags if args.tags is not None else current.get("tags", [])
if not str(title or "").strip():
raise revise("标题不能为空", "补充非空标题后重新生成计划并取得新的确认码")
return {
"title": str(title).strip(),
"description": str(description or ""),
"tags": [str(tag).strip() for tag in tags if str(tag).strip()],
"images": validate_images(args.image_urls),
}
def cmd_projects(args: argparse.Namespace) -> None:
base = base_url(args.base_url)
print_json([project_identity(item) | {"review_status": item.get("review_status")} for item in projects(base)])
def cmd_works(args: argparse.Namespace) -> None:
base = base_url(args.base_url)
project = exact_project(base, args.project_id)
query = f"?externalId={quote(args.external_id)}" if args.external_id else ""
_, value = request_json(base, f"/api/projects/{args.project_id}/works{query}")
print_json({"target": project_identity(project), "works": value})
def cmd_inspect(args: argparse.Namespace) -> None:
base = base_url(args.base_url)
project = exact_project(base, args.project_id)
work = get_work(base, args.work_id)
assert_work_project(work, args.project_id)
print_json({"target": project_identity(project), "work": work})
def cmd_plan_work(args: argparse.Namespace) -> None:
base = base_url(args.base_url)
project = exact_project(base, args.project_id)
if project.get("status") != "active":
raise UploadError("目标项目不是 active不能创建作品")
if not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", args.external_id):
raise revise("externalId 格式无效", "改用符合 [A-Za-z0-9._:-]{1,128} 的稳定 externalId 后重新生成计划")
plan = {
"schema_version": 1,
"operation": "create_work",
"base_url": base,
"target": project_identity(project),
"external_id": args.external_id,
"content": content_from_args(args),
}
save_plan(plan, args.output)
def cmd_plan_round(args: argparse.Namespace) -> None:
base = base_url(args.base_url)
project = exact_project(base, args.project_id)
if project.get("status") != "active":
raise UploadError("目标项目不是 active不能创建验收轮次")
work = get_work(base, args.work_id)
assert_work_project(work, args.project_id)
rounds = work.get("rounds") or []
current_round = max((int(item.get("round_number", 0)) for item in rounds), default=0)
plan = {
"schema_version": 1,
"operation": "create_round",
"base_url": base,
"target": project_identity(project),
"work": {
"work_id": args.work_id,
"external_id": work.get("external_id"),
"expected_version_number": int(work.get("version_number", 0)),
"current_round": current_round,
"next_round": current_round + 1,
},
"content": content_from_args(args, work),
}
save_plan(plan, args.output)
def same_content(detail: dict[str, Any], expected: dict[str, Any]) -> bool:
images = [str(item.get("url") or "") for item in detail.get("images", [])]
try:
returned_images = validate_images(images)
except UploadError:
return False
return (
detail.get("title") == expected["title"]
and detail.get("description", "") == expected["description"]
and detail.get("tags", []) == expected["tags"]
and len(returned_images) == len(expected["images"])
)
def cmd_apply(args: argparse.Namespace) -> None:
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
expected_code = confirmation_code(plan)
if args.confirm != expected_code or plan.get("confirmation_code") != expected_code:
raise revise("确认码不匹配;计划可能已改变,禁止执行", "重新展示当前计划并取得与当前计划一致的新确认码")
if plan.get("schema_version") != 1 or plan.get("operation") not in {"create_work", "create_round"}:
raise UploadError("不支持的计划格式")
base = base_url(plan.get("base_url"))
target = plan["target"]
project = exact_project(base, int(target["project_id"]))
actual_identity = project_identity(project)
if actual_identity != target:
raise UploadError(f"项目身份或状态已变化,禁止执行。计划={target},当前={actual_identity}")
content = plan["content"]
if plan["operation"] == "create_work":
external_id = plan["external_id"]
_, existing = request_json(base, f"/api/projects/{target['project_id']}/works?externalId={quote(external_id)}")
if existing:
print_json({"success": True, "idempotent": True, "message": "externalId 已存在,未发送创建请求", "target": target, "work": existing[0]})
return
_, created = request_json(base, f"/api/projects/{target['project_id']}/works", method="POST", body={**content, "externalId": external_id})
work_id = int(created["id"])
verified = get_work(base, work_id)
assert_work_project(verified, int(target["project_id"]))
if verified.get("external_id") != external_id or not same_content(verified, content):
raise UploadError("创建请求返回成功,但回读内容不一致")
print_json({"success": True, "idempotent": bool(created.get("idempotent")), "target": target, "work_id": work_id, "external_id": external_id, "round_number": 1, "title": verified["title"], "image_count": len(verified["images"])})
return
work_plan = plan["work"]
before = get_work(base, int(work_plan["work_id"]))
assert_work_project(before, int(target["project_id"]))
if int(before.get("version_number", 0)) != int(work_plan["expected_version_number"]):
raise UploadError("作品当前版本已变化,旧计划失效;请重新生成计划并确认")
_, created = request_json(base, f"/api/works/{work_plan['work_id']}/rounds", method="POST", body=content)
verified = get_work(base, int(work_plan["work_id"]))
if int(verified.get("version_number", 0)) != int(work_plan["expected_version_number"]) + 1 or not same_content(verified, content):
raise UploadError("新轮次请求返回成功,但回读轮次或内容不一致")
rounds = verified.get("rounds") or []
round_number = max((int(item.get("round_number", 0)) for item in rounds), default=0)
print_json({"success": True, "target": target, "work_id": int(work_plan["work_id"]), "external_id": verified.get("external_id"), "round_number": round_number, "title": verified["title"], "image_count": len(verified["images"]), "server_response_id": created.get("id")})
def add_common(command: argparse.ArgumentParser) -> None:
command.add_argument("--base-url", default=None)
def add_content(command: argparse.ArgumentParser, *, title_required: bool) -> None:
command.add_argument("--title", required=title_required, default=None)
command.add_argument("--description", default=None)
command.add_argument("--clear-description", action="store_true")
command.add_argument("--tag", action="append", dest="tags", default=None)
command.add_argument("--clear-tags", action="store_true")
command.add_argument("--image-url", action="append", dest="image_urls", required=True)
def build_parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(description="安全发现、规划并上传 Delivery Desk 作品")
commands = root.add_subparsers(dest="command", required=True)
command = commands.add_parser("projects")
add_common(command)
command.set_defaults(handler=cmd_projects)
command = commands.add_parser("works")
add_common(command)
command.add_argument("--project-id", type=int, required=True)
command.add_argument("--external-id")
command.set_defaults(handler=cmd_works)
command = commands.add_parser("inspect-work")
add_common(command)
command.add_argument("--project-id", type=int, required=True)
command.add_argument("--work-id", type=int, required=True)
command.set_defaults(handler=cmd_inspect)
command = commands.add_parser("plan-work")
add_common(command)
command.add_argument("--project-id", type=int, required=True)
command.add_argument("--external-id", required=True)
add_content(command, title_required=True)
command.add_argument("--output", required=True)
command.set_defaults(handler=cmd_plan_work)
command = commands.add_parser("plan-round")
add_common(command)
command.add_argument("--project-id", type=int, required=True)
command.add_argument("--work-id", type=int, required=True)
add_content(command, title_required=False)
command.add_argument("--output", required=True)
command.set_defaults(handler=cmd_plan_round)
command = commands.add_parser("apply")
command.add_argument("--plan", required=True)
command.add_argument("--confirm", required=True)
command.set_defaults(handler=cmd_apply)
return root
def main() -> int:
args = build_parser().parse_args()
args.handler(args)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (UploadError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
failure = error if isinstance(error, UploadError) else UploadError(f"输入或响应格式无效: {error}")
print(json.dumps(failure.payload(), ensure_ascii=False), file=sys.stderr)
raise SystemExit(1)

View File

@@ -1,8 +1,10 @@
# Delivery Desk 开发约定
- 包管理器使用 pnpm提交前运行 `pnpm check``pnpm lint``pnpm build``pnpm test:postgres-runtime`
- 业务术语统一为“运营组 → 项目 → 作品交付集 → 作品 → 版本”。`collections` 只是内部数据库与路由标识,用户界面和文档不再称“作品集”或“阶段任务”
- 数据库结构变更必须同时更`api/db.ts``db/postgres/schema.sql` 和相关迁移验证
- 包管理器使用 pnpm提交前运行 `pnpm check``pnpm lint``pnpm build``pnpm test:review-rounds``pnpm test:collection-status``pnpm test:postgres-runtime``pnpm db:postgres:validate``python tests/test_upload_skill.py`
- 业务术语统一为“运营组 → 项目 → 作品 → 验收轮次”;每轮只有一个方案。`collections``work_versions` 是迁移期内部兼容结构,不得出现在新产品界面或新 API 命名中
-接口使用 `/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 配置只通过平台管理界面或部署密钥注入。
- 不把 `.trae/` 中的早期原型文档作为现行依据;以 README、`docs/` 和当前代码为准。
- Agent 通过 API 上传作品或创建验收轮次时必须使用 `.agents/skills/upload-delivery-desk-work`;接口或层级变化后同步更新该 Skill并运行 `powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1` 重新打包。

View File

@@ -1,27 +1,20 @@
# 交付工作台Delivery Desk
面向图文作品交付与客户验收的响应式 Web 工作台。业务层级为“运营组 → 项目 → 作品交付集 → 作品 → 版本”。运营人员负责上传和处理反馈,客户通过项目链接完成查看、批注与验收
面向图文作品交付与客户验收的响应式 Web 工作台。当前产品层级为“运营组 → 项目 → 作品 → 验收轮次”;每轮只有一个方案
## 当前能力
- 平台管理员、组管理员、光影叙事三类账号及运营组数据隔离
- 项目、作品交付集、作品和作品版本管理
- 多图上传、封面预览、图片排序和腾讯云 COS 存储
- 图片坐标批注、标题/正文批注、总体反馈和验收记录
- 项目、作品、单方案验收轮次和项目级自动验收状态
- 手动多图上传、公开图片 URL 自动归一到腾讯云 COS、拖拽排序
- 作品缩略图浏览;悬浮图片窗格中的原图查看、缩放和坐标批注
- 标题、正文和 Tag 选区批注、作品总体反馈和验收记录
- 客户项目密码、访问期限和独立验收入口
- 平台级/项目级 API Key、审计日志和账号管理
- SQLite 本地开发、PostgreSQL 正式运行及迁移脚本
- Docker 单机部署
- 项目内置 Agent 安全上传 Skill使用双阶段确认防止错组、错项目和错作品
- SQLite 本地开发、PostgreSQL 正式运行及 Docker 部署
未落地范围见 [初版交接说明](docs/handoff.md)。
## 技术结构
- React 18、TypeScript、Vite、Tailwind CSS
- Express API
- 本地开发SQLite 与本地 `uploads`
- 正式环境PostgreSQL、腾讯云 COS
- COS SecretId/SecretKey 由平台管理员在前端配置,服务端使用 AES-256-GCM 加密,接口不返回明文
未落地范围见 [初版交接说明](docs/handoff.md)。
## 本地开发
@@ -32,61 +25,42 @@ pnpm install
pnpm dev
```
- 前端:`http://localhost:5180`
- API`http://localhost:3010`
- 健康检查:`http://localhost:3010/api/health`
- 前端http://localhost:5180
- APIhttp://localhost:3010
- 健康检查http://localhost:3010/api/health
未配置 `DATABASE_URL` 时使用 `data/app.db`;本地图片保存在 `uploads/`这两个目录包含运行数据、账号信息或用户文件,已排除在 Git 之外。
未配置 `DATABASE_URL` 时使用 `data/app.db`;本地上传文件保存在 `uploads/`两者均包含运行数据或用户文件,已排除在 Git 之外。
SQLite 首次启动会创建开发账号并要求首次登录改密。不要把这些开发账号用于公网环境
复制 `.env.example``.env` 后配置环境变量。生产环境至少需要 `DATABASE_URL``COS_CONFIG_ENCRYPTION_KEY` 和安全的初始管理员密码。真实 COS 凭证只能通过平台管理界面或部署密钥注入,不能提交到 Git
## 环境配置
复制 `.env.example``.env`,再按环境填写。关键变量:
| 变量 | 用途 |
|---|---|
| `DATABASE_URL` | PostgreSQL 连接串;留空时使用 SQLite |
| `PGSSL` / `PG_POOL_MAX` | PostgreSQL SSL 与连接池配置 |
| `CORS_ORIGIN` | 允许携带凭据访问 API 的前端来源,多个值以逗号分隔 |
| `COS_CONFIG_ENCRYPTION_KEY` | 加密前端保存的 COS 凭证,至少 32 个随机字符 |
| `INITIAL_ADMIN_PASSWORD` | 空 PostgreSQL 首次初始化的平台管理员临时密码 |
`COS_CONFIG_ENCRYPTION_KEY` 一经用于保存 COS 配置后必须稳定保管,更换会导致旧密文无法解密。真实 COS 凭证不得写入 `.env.example`、源码、镜像或日志。
## PostgreSQL 迁移
## PostgreSQL 与 Docker
```bash
pnpm db:postgres:validate
pnpm db:postgres:migrate
```
目标数据库已有数据时迁移会拒绝覆盖。确认替换时才可执行:
```bash
pnpm db:postgres:migrate -- --replace
```
## Docker 部署
`.env` 中至少设置 `POSTGRES_PASSWORD``COS_CONFIG_ENCRYPTION_KEY``INITIAL_ADMIN_PASSWORD`,然后运行:
```bash
docker compose up -d --build
```
应用通过 `http://服务器地址:3010` 同时提供前端与 API。公网环境应在前面配置 HTTPS 反向代理;生产 Cookie 会自动添加 `Secure`
目标 PostgreSQL 已有数据时迁移默认拒绝覆盖。仅确认替换时使用 `pnpm db:postgres:migrate -- --replace`
## 文档与检查
- [架构与数据模型](docs/architecture.md)
- [API 接入指南](docs/integration-guide.md)
- [部署与运维手册](docs/operator-runbook.md)
- [初版交接说明](docs/handoff.md)
## 检查
```bash
pnpm check
pnpm lint
pnpm build
pnpm test:review-rounds
pnpm test:collection-status
pnpm test:postgres-runtime
pnpm db:postgres:validate
python tests/test_upload_skill.py
```
修改内置 Agent 上传 Skill 后,运行 `powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1` 更新 `skill-packages/upload-delivery-desk-work.zip`。该 ZIP 可直接上传到 Skill 安装器,根目录必须直接包含 `SKILL.md`
更多资料:
- [架构与数据模型](docs/architecture.md)
- [API 接入指南](docs/integration-guide.md)
- [部署与运维手册](docs/operator-runbook.md)
- [初版交接说明](docs/handoff.md)

View File

@@ -22,6 +22,8 @@ import groupsRoutes from './routes/groups.js';
import managementRoutes from './routes/management.js';
import storageRoutes from './routes/storage.js';
import reviewRoutes from './routes/review.js';
import worksRoutes from './routes/works.js';
import projectWorksRoutes from './routes/projectWorks.js';
import { UPLOADS_DIR } from './upload.js';
import { database, databaseDialect } from './database.js';
@@ -50,6 +52,8 @@ app.use(
* API 路由
*/
app.use('/api/notes', notesRoutes);
app.use('/api/works', worksRoutes);
app.use('/api/projects/:projectId/works', projectWorksRoutes);
app.use('/api/images', imagesRoutes);
app.use('/api/annotations', annotationsRoutes);
app.use('/api/projects', projectsRoutes);
@@ -82,13 +86,18 @@ if (process.env.NODE_ENV === 'production' && fs.existsSync(distDir)) {
*/
app.use((err: Error, _req: Request, res: Response, _next: unknown) => {
void _next;
console.error('[API Error]', err);
// multer 文件类型/大小错误
const message = err.message || '服务器内部错误';
const statusCode = Number((err as Error & { statusCode?: number }).statusCode);
if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599) {
res.status(statusCode).json({ success: false, error: message });
return;
}
if (err.message?.includes('不支持的文件类型')) {
res.status(400).json({ success: false, error: message });
return;
}
console.error('[API Error]', err);
res.status(500).json({ success: false, error: message });
});

View File

@@ -25,7 +25,15 @@ if (databaseUrl) {
pool = new pg.Pool({ connectionString: databaseUrl, ssl: process.env.PGSSL === 'disable' ? false : undefined, max: Number(process.env.PG_POOL_MAX || 10) });
}
let schema = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8');
if (databaseUrl === 'pg-mem://') schema = schema.replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '');
if (databaseUrl === 'pg-mem://') {
schema = schema
.replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '')
.replace(/-- COLLECTION_STATUS_REPAIR_START[\s\S]+?-- COLLECTION_STATUS_REPAIR_END/, '')
.replace(/-- REVIEW_ROUND_REPAIR_START[\s\S]+?-- REVIEW_ROUND_REPAIR_END/, '')
.replace(/-- PROJECT_REVIEW_STATUS_REPAIR_START[\s\S]+?-- PROJECT_REVIEW_STATUS_REPAIR_END/, '')
.replace(/-- TEXT_ANNOTATION_TARGET_REPAIR_START[\s\S]+?-- TEXT_ANNOTATION_TARGET_REPAIR_END/, '')
.replace(/-- SINGLE_SCHEME_REPAIR_START[\s\S]+?-- SINGLE_SCHEME_REPAIR_END/, '');
}
await pool.query(schema);
const userCount = Number((await pool.query('SELECT COUNT(*)::int AS count FROM users')).rows[0].count);
if (userCount === 0) {

169
api/db.ts
View File

@@ -102,6 +102,8 @@ db.exec(`
slug TEXT NOT NULL UNIQUE,
client_description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
review_status TEXT NOT NULL DEFAULT 'draft',
review_completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS collections (
@@ -109,12 +111,14 @@ db.exec(`
project_id INTEGER NOT NULL,
name TEXT NOT NULL,
client_description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'reviewing',
status TEXT NOT NULL DEFAULT 'draft',
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER,
external_id TEXT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
@@ -135,16 +139,23 @@ db.exec(`
x REAL NOT NULL,
y REAL NOT NULL,
content TEXT NOT NULL,
author_role TEXT NOT NULL DEFAULT 'client',
status TEXT NOT NULL DEFAULT 'open',
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS work_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
version_number INTEGER NOT NULL DEFAULT 1,
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client',
status TEXT NOT NULL DEFAULT 'open',
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
@@ -153,9 +164,30 @@ db.exec(`
note_id INTEGER NOT NULL,
version_number INTEGER NOT NULL,
target TEXT NOT NULL,
start_offset INTEGER NOT NULL DEFAULT 0,
end_offset INTEGER NOT NULL DEFAULT 0,
selected_text TEXT NOT NULL DEFAULT '',
prefix_text TEXT NOT NULL DEFAULT '',
suffix_text TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client',
status TEXT NOT NULL DEFAULT 'open',
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS feedback_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
version_number INTEGER NOT NULL,
feedback_type TEXT NOT NULL,
feedback_id INTEGER NOT NULL,
content TEXT NOT NULL,
author_name TEXT NOT NULL,
author_role TEXT NOT NULL,
withdrawn_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
@@ -173,6 +205,20 @@ db.exec(`
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS review_rounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
round_number INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'reviewing',
selected_version_number INTEGER,
created_by INTEGER,
completed_at TEXT,
completion_reason TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(note_id, round_number),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS review_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
@@ -197,13 +243,21 @@ function addColumn(table: string, definition: string) {
}
addColumn('notes', "collection_id INTEGER");
addColumn('notes', 'project_id INTEGER');
addColumn('notes', 'external_id TEXT');
addColumn('notes', "tags TEXT NOT NULL DEFAULT '[]'");
addColumn('notes', "review_status TEXT NOT NULL DEFAULT 'pending'");
addColumn('notes', 'version_number INTEGER NOT NULL DEFAULT 1');
addColumn('notes', 'active_round_id INTEGER');
addColumn('notes', 'approved_version_number INTEGER');
addColumn('annotations', "author_name TEXT NOT NULL DEFAULT '客户'");
addColumn('annotations', "status TEXT NOT NULL DEFAULT 'open'");
addColumn('annotations', "author_role TEXT NOT NULL DEFAULT 'client'");
addColumn('annotations', 'withdrawn_at TEXT');
addColumn('annotations', "closure_reason TEXT NOT NULL DEFAULT ''");
addColumn('projects', 'group_id INTEGER');
addColumn('projects', "review_status TEXT NOT NULL DEFAULT 'draft'");
addColumn('projects', 'review_completed_at TEXT');
addColumn('projects', "access_password_hash TEXT NOT NULL DEFAULT ''");
addColumn('projects', 'customer_access_enabled INTEGER NOT NULL DEFAULT 0');
addColumn('projects', 'access_expires_at TEXT');
@@ -211,6 +265,22 @@ addColumn('images', "storage_provider TEXT NOT NULL DEFAULT 'local'");
addColumn('images', "storage_key TEXT NOT NULL DEFAULT ''");
addColumn('images', 'version_number INTEGER NOT NULL DEFAULT 1');
addColumn('users', 'last_login_at TEXT');
addColumn('collections', 'completed_at TEXT');
addColumn('work_comments', 'version_number INTEGER NOT NULL DEFAULT 1');
addColumn('work_comments', 'withdrawn_at TEXT');
addColumn('work_comments', "closure_reason TEXT NOT NULL DEFAULT ''");
addColumn('text_annotations', 'start_offset INTEGER NOT NULL DEFAULT 0');
addColumn('text_annotations', 'end_offset INTEGER NOT NULL DEFAULT 0');
addColumn('text_annotations', "selected_text TEXT NOT NULL DEFAULT ''");
addColumn('text_annotations', "prefix_text TEXT NOT NULL DEFAULT ''");
addColumn('text_annotations', "suffix_text TEXT NOT NULL DEFAULT ''");
addColumn('text_annotations', "author_role TEXT NOT NULL DEFAULT 'client'");
addColumn('text_annotations', 'withdrawn_at TEXT');
addColumn('text_annotations', "closure_reason TEXT NOT NULL DEFAULT ''");
addColumn('work_versions', 'review_round_id INTEGER');
addColumn('work_versions', "candidate_name TEXT NOT NULL DEFAULT '方案 A'");
addColumn('work_versions', "candidate_status TEXT NOT NULL DEFAULT 'pending'");
addColumn('review_rounds', "completion_reason TEXT NOT NULL DEFAULT ''");
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_collection_external_id ON notes(collection_id, external_id) WHERE external_id IS NOT NULL AND external_id != ''");
@@ -250,18 +320,79 @@ if (!collectionId) {
}
db.prepare('UPDATE notes SET collection_id = ? WHERE collection_id IS NULL').run(collectionId);
db.prepare('UPDATE projects SET group_id = ? WHERE group_id IS NULL').run(groupId);
db.prepare('UPDATE notes SET project_id = (SELECT c.project_id FROM collections c WHERE c.id = notes.collection_id) WHERE project_id IS NULL').run();
db.prepare('UPDATE work_comments SET version_number = (SELECT n.version_number FROM notes n WHERE n.id = work_comments.note_id) WHERE version_number IS NULL OR version_number < 1').run();
db.prepare(`UPDATE collections SET name = '2026 年 7 月任务', client_description = '本月内容作品交付集'
WHERE project_id = (SELECT id FROM projects WHERE slug = 'light-notes') AND (name LIKE '%?%' OR client_description LIKE '%?%')`).run();
db.prepare(`INSERT OR IGNORE INTO work_versions (note_id, version_number, title, description, tags, review_status)
SELECT id, version_number, title, description, tags, review_status FROM notes`).run();
db.exec(`
INSERT OR IGNORE INTO review_rounds (note_id, round_number, status, selected_version_number, completed_at, created_at)
SELECT v.note_id, v.version_number,
CASE WHEN v.version_number = n.version_number AND n.review_status != 'approved' THEN 'reviewing' ELSE 'completed' END,
CASE WHEN v.review_status = 'approved' THEN v.version_number ELSE NULL END,
CASE WHEN v.version_number = n.version_number AND n.review_status != 'approved' THEN NULL ELSE v.created_at END,
v.created_at
FROM work_versions v JOIN notes n ON n.id = v.note_id;
UPDATE work_versions
SET review_round_id = (SELECT r.id FROM review_rounds r WHERE r.note_id = work_versions.note_id AND r.round_number = work_versions.version_number),
candidate_name = COALESCE(NULLIF(candidate_name, ''), '方案 A'),
candidate_status = CASE
WHEN review_status = 'approved' THEN 'selected'
WHEN version_number = (SELECT version_number FROM notes WHERE id = work_versions.note_id) AND review_status = 'changes_requested' THEN 'changes_requested'
WHEN version_number = (SELECT version_number FROM notes WHERE id = work_versions.note_id) AND review_status = 'pending' THEN 'pending'
WHEN version_number = (SELECT version_number FROM notes WHERE id = work_versions.note_id) AND review_status = 'draft' THEN 'draft'
ELSE 'not_selected'
END
WHERE review_round_id IS NULL;
UPDATE notes
SET active_round_id = (SELECT r.id FROM review_rounds r WHERE r.note_id = notes.id AND r.round_number = notes.version_number),
approved_version_number = (SELECT MAX(v.version_number) FROM work_versions v WHERE v.note_id = notes.id AND v.review_status = 'approved')
WHERE active_round_id IS NULL;
`);
db.exec(`
UPDATE projects
SET review_status = CASE
WHEN status = 'archived' THEN 'archived'
WHEN NOT EXISTS (SELECT 1 FROM notes n WHERE n.project_id = projects.id AND n.review_status != 'draft') THEN 'draft'
WHEN NOT EXISTS (SELECT 1 FROM notes n WHERE n.project_id = projects.id AND n.review_status != 'draft' AND n.review_status != 'approved') THEN 'completed'
ELSE 'reviewing'
END,
review_completed_at = CASE
WHEN status != 'archived'
AND EXISTS (SELECT 1 FROM notes n WHERE n.project_id = projects.id AND n.review_status != 'draft')
AND NOT EXISTS (SELECT 1 FROM notes n WHERE n.project_id = projects.id AND n.review_status != 'draft' AND n.review_status != 'approved')
THEN COALESCE(review_completed_at, datetime('now'))
ELSE NULL
END;
`);
db.exec(`
UPDATE collections
SET status = CASE
WHEN NOT EXISTS (SELECT 1 FROM notes n WHERE n.collection_id = collections.id AND n.review_status != 'draft') THEN 'draft'
WHEN NOT EXISTS (SELECT 1 FROM notes n WHERE n.collection_id = collections.id AND n.review_status != 'draft' AND n.review_status != 'approved') THEN 'completed'
ELSE 'reviewing'
END,
completed_at = CASE
WHEN EXISTS (SELECT 1 FROM notes n WHERE n.collection_id = collections.id AND n.review_status != 'draft')
AND NOT EXISTS (SELECT 1 FROM notes n WHERE n.collection_id = collections.id AND n.review_status != 'draft' AND n.review_status != 'approved')
THEN COALESCE(completed_at, datetime('now'))
ELSE NULL
END
WHERE status != 'archived';
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_collections_project_id ON collections(project_id);
CREATE INDEX IF NOT EXISTS idx_notes_collection_id ON notes(collection_id);
CREATE INDEX IF NOT EXISTS idx_notes_project_id ON notes(project_id);
CREATE INDEX IF NOT EXISTS idx_images_note_id ON images(note_id);
CREATE INDEX IF NOT EXISTS idx_annotations_image_id ON annotations(image_id);
CREATE INDEX IF NOT EXISTS idx_comments_note_id ON work_comments(note_id);
CREATE INDEX IF NOT EXISTS idx_text_annotations_note_id ON text_annotations(note_id, version_number);
CREATE INDEX IF NOT EXISTS idx_feedback_replies_target ON feedback_replies(note_id, version_number, feedback_type, feedback_id);
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_customer_sessions_token_hash ON customer_sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_customer_sessions_project_id ON customer_sessions(project_id);
@@ -271,7 +402,43 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX IF NOT EXISTS idx_storage_configs_status ON storage_configs(status);
CREATE INDEX IF NOT EXISTS idx_work_versions_note_id ON work_versions(note_id, version_number);
CREATE INDEX IF NOT EXISTS idx_review_rounds_note_id ON review_rounds(note_id, round_number);
CREATE INDEX IF NOT EXISTS idx_review_events_note_id ON review_events(note_id, version_number);
`);
export function repairMultiSchemeRounds() {
db.transaction(() => {
const duplicates = db.prepare(`SELECT review_round_id, note_id FROM work_versions WHERE review_round_id IS NOT NULL GROUP BY review_round_id, note_id HAVING COUNT(*) > 1`).all() as Array<{ review_round_id: number; note_id: number }>;
for (const duplicate of duplicates) {
const round = db.prepare('SELECT * FROM review_rounds WHERE id=?').get(duplicate.review_round_id) as Record<string, unknown> | undefined;
const note = db.prepare('SELECT active_round_id,version_number FROM notes WHERE id=?').get(duplicate.note_id) as { active_round_id: number | null; version_number: number } | undefined;
const versions = db.prepare('SELECT version_number FROM work_versions WHERE review_round_id=? ORDER BY version_number').all(duplicate.review_round_id) as Array<{ version_number: number }>;
if (!round || !note || versions.length < 2) continue;
const keeper = versions.some((item) => Number(item.version_number) === Number(note.version_number)) ? Number(note.version_number) : Number(versions[0].version_number);
for (const version of versions.filter((item) => Number(item.version_number) !== keeper)) {
const nextRound = Number((db.prepare('SELECT COALESCE(MAX(round_number),0)+1 AS value FROM review_rounds WHERE note_id=?').get(duplicate.note_id) as { value: number }).value);
const remainsActive = Number(note.active_round_id) === Number(duplicate.review_round_id) && Number(note.version_number) === Number(version.version_number);
const result = db.prepare(`INSERT INTO review_rounds (note_id,round_number,status,selected_version_number,completed_at,created_by,created_at,completion_reason)
VALUES (?,?,?,?,?,?,?,?)`).run(
duplicate.note_id,
nextRound,
remainsActive ? round.status : 'completed',
Number(round.selected_version_number) === Number(version.version_number) ? version.version_number : null,
remainsActive ? round.completed_at : (round.completed_at || new Date().toISOString()),
round.created_by ?? null,
round.created_at,
remainsActive ? round.completion_reason : (round.completion_reason || 'migrated_single_scheme'),
);
const newRoundId = Number(result.lastInsertRowid);
db.prepare('UPDATE work_versions SET review_round_id=? WHERE note_id=? AND version_number=?').run(newRoundId, duplicate.note_id, version.version_number);
if (remainsActive) db.prepare('UPDATE notes SET active_round_id=? WHERE id=?').run(newRoundId, duplicate.note_id);
}
if (Number(round.selected_version_number) !== keeper) db.prepare('UPDATE review_rounds SET selected_version_number=NULL WHERE id=?').run(duplicate.review_round_id);
}
})();
}
repairMultiSchemeRounds();
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_project_external_id ON notes(project_id, external_id) WHERE external_id IS NOT NULL AND external_id != ''");
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_work_versions_one_per_round ON work_versions(review_round_id) WHERE review_round_id IS NOT NULL');
export default db;

View File

@@ -6,11 +6,11 @@ function toAnnotation(row: AnnotationRow): Annotation { return { ...row, id: Num
export const annotationsRepository = {
async listByImage(imageId: number): Promise<Annotation[]> {
return (await database.all<AnnotationRow>('SELECT id, image_id, x, y, content, author_name, status, created_at FROM annotations WHERE image_id = ? ORDER BY id ASC', [imageId])).map(toAnnotation);
return (await database.all<AnnotationRow>('SELECT id, image_id, x, y, content, author_name, author_role, status, closure_reason, withdrawn_at, created_at FROM annotations WHERE image_id = ? ORDER BY id ASC', [imageId])).map(toAnnotation);
},
async create(imageId: number, data: CreateAnnotationRequest): Promise<Annotation> {
const id = await database.insertId('INSERT INTO annotations (image_id, x, y, content, author_name) VALUES (?, ?, ?, ?, ?)', [imageId, data.x, data.y, data.content, data.author_name || '客户']);
return toAnnotation((await database.one<AnnotationRow>('SELECT id, image_id, x, y, content, author_name, status, created_at FROM annotations WHERE id = ?', [id]))!);
const id = await database.insertId('INSERT INTO annotations (image_id, x, y, content, author_name, author_role) VALUES (?, ?, ?, ?, ?, ?)', [imageId, data.x, data.y, data.content, data.author_name || '客户', data.author_role || 'client']);
return toAnnotation((await database.one<AnnotationRow>('SELECT id, image_id, x, y, content, author_name, author_role, status, closure_reason, withdrawn_at, created_at FROM annotations WHERE id = ?', [id]))!);
},
async remove(id: number): Promise<boolean> { return (await database.execute('DELETE FROM annotations WHERE id = ?', [id])).changes > 0; },
};

View File

@@ -2,15 +2,16 @@ import { database } from '../database.js';
import type { Note, NoteListQuery, ReviewStatus } from '../../shared/types.js';
interface NoteRow {
id: number; collection_id: number; external_id: string | null; title: string; description: string; tags: string;
review_status: ReviewStatus; version_number: number; created_at: string;
id: number; project_id: number; collection_id: number; external_id: string | null; title: string; description: string; tags: string;
review_status: ReviewStatus; version_number: number; active_round_id: number | null; approved_version_number: number | null; created_at: string;
image_count: number | string; annotation_count: number | string; comment_count: number | string; cover_url: string | null;
}
function toNote(row: NoteRow): Note {
return {
...row,
id: Number(row.id), collection_id: Number(row.collection_id), version_number: Number(row.version_number),
id: Number(row.id), project_id: Number(row.project_id), collection_id: Number(row.collection_id), version_number: Number(row.version_number),
active_round_id: row.active_round_id == null ? null : Number(row.active_round_id), approved_version_number: row.approved_version_number == null ? null : Number(row.approved_version_number),
image_count: Number(row.image_count), annotation_count: Number(row.annotation_count), comment_count: Number(row.comment_count),
tags: JSON.parse(row.tags || '[]') as string[],
cover_image: row.cover_url ? (/^https?:\/\//.test(row.cover_url) || row.cover_url.startsWith('/') ? row.cover_url : `/uploads/${row.cover_url}`) : '',
@@ -18,7 +19,7 @@ function toNote(row: NoteRow): Note {
}
const select = `
SELECT n.id, n.collection_id, n.external_id, n.title, n.description, n.tags, n.review_status, n.version_number, n.created_at,
SELECT n.id, n.project_id, n.collection_id, n.external_id, n.title, n.description, n.tags, n.review_status, n.version_number, n.active_round_id, n.approved_version_number, n.created_at,
COALESCE(ic.image_count, 0) AS image_count,
COALESCE(ac.annotation_count, 0) AS annotation_count,
COALESCE(cc.comment_count, 0) AS comment_count,
@@ -37,8 +38,8 @@ export const notesRepository = {
if (query.status) { conditions.push('n.review_status = ?'); params.push(query.status); }
if (query.q?.trim()) { conditions.push('(n.title LIKE ? OR n.description LIKE ?)'); params.push(`%${query.q.trim()}%`, `%${query.q.trim()}%`); }
if (query.tag?.trim()) { conditions.push('n.tags LIKE ?'); params.push(`%"${query.tag.trim()}"%`); }
if (query.projectId) { conditions.push('n.collection_id IN (SELECT id FROM collections WHERE project_id = ?)'); params.push(query.projectId); }
if (query.groupId) { conditions.push('n.collection_id IN (SELECT c.id FROM collections c JOIN projects p ON p.id = c.project_id WHERE p.group_id = ?)'); params.push(query.groupId); }
if (query.projectId) { conditions.push('n.project_id = ?'); params.push(query.projectId); }
if (query.groupId) { conditions.push('n.project_id IN (SELECT id FROM projects WHERE group_id = ?)'); params.push(query.groupId); }
const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
const order = query.order === 'asc' ? 'ASC' : 'DESC';
const sort = query.sort === 'annotations' ? 'annotation_count' : 'n.created_at';
@@ -52,6 +53,10 @@ export const notesRepository = {
const row = await database.one<NoteRow>(`${select} WHERE n.collection_id = ? AND n.external_id = ?`, [collectionId, externalId]);
return row ? toNote(row) : null;
},
async findByProjectExternalId(projectId: number, externalId: string): Promise<Note | null> {
const row = await database.one<NoteRow>(`${select} WHERE n.project_id = ? AND n.external_id = ?`, [projectId, externalId]);
return row ? toNote(row) : null;
},
async create(title: string, description: string, collectionId: number, tags: string[]): Promise<number> {
return database.insertId('INSERT INTO notes (title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?)', [title, description, collectionId, JSON.stringify(tags), 'pending']);
},

View File

@@ -1,5 +1,4 @@
import { Router, type Response } from 'express';
import { annotationsRepository } from '../repositories/annotationsRepository.js';
import { canWriteProject, requireWriter, type AuthRequest } from '../auth.js';
import { database } from '../database.js';
@@ -8,10 +7,13 @@ const router = Router();
router.delete('/:annotationId', requireWriter, async (req: AuthRequest, res: Response) => {
const id = Number(req.params.annotationId);
if (!Number.isFinite(id)) { res.status(400).json({ error: '无效的批注 ID' }); return; }
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM annotations a JOIN images i ON i.id = a.image_id JOIN notes n ON n.id = i.note_id JOIN collections c ON c.id = n.collection_id WHERE a.id = ?', [id]);
const context = await database.one<{ project_id: number; version_number: number; current_version: number; author_name: string; author_role: string; round_status: string; project_status: string; project_review_status: string }>(`SELECT n.project_id,i.version_number,n.version_number AS current_version,a.author_name,a.author_role,r.status AS round_status,p.status AS project_status,p.review_status AS project_review_status
FROM annotations a JOIN images i ON i.id=a.image_id JOIN notes n ON n.id=i.note_id JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE a.id=?`, [id]);
if (!context) { res.status(404).json({ error: '批注不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该批注' }); return; }
await annotationsRepository.remove(id);
if (context.project_status !== 'active' || context.project_review_status === 'completed' || context.round_status !== 'reviewing' || Number(context.version_number) !== Number(context.current_version)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
if (context.author_role !== 'operator' || context.author_name !== (req.authUser?.display_name || 'API')) { res.status(403).json({ error: '只能撤回自己提交的批注' }); return; }
await database.execute('UPDATE annotations SET withdrawn_at=CURRENT_TIMESTAMP WHERE id=? AND withdrawn_at IS NULL', [id]);
res.status(204).end();
});

View File

@@ -7,20 +7,22 @@ const router = Router();
router.post('/notes/:noteId/comments', requireWriter, async (req: AuthRequest, res: Response) => {
const noteId = Number(req.params.noteId); const content = String(req.body?.content ?? '').trim();
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [noteId]);
const context = await database.one<{ project_id: number; version_number: number; round_status: string; project_status: string; project_review_status: string }>('SELECT n.project_id,n.version_number,r.status AS round_status,p.status AS project_status,p.review_status AS project_review_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id = ?', [noteId]);
if (!context) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (context.project_status !== 'active' || context.project_review_status === 'completed' || context.round_status !== 'reviewing') { res.status(409).json({ error: '当前轮次、已关闭或已完成项目为只读状态' }); return; }
if (!content || content.length > 2000) { res.status(400).json({ error: '回复内容须为 12000 个字符' }); return; }
const id = await database.insertId("INSERT INTO work_comments (note_id, content, author_name, author_role) VALUES (?, ?, ?, 'operator')", [noteId, content, req.authUser?.display_name || 'API']);
const id = await database.insertId("INSERT INTO work_comments (note_id, version_number, content, author_name, author_role) VALUES (?, ?, ?, ?, 'operator')", [noteId, context.version_number, content, req.authUser?.display_name || 'API']);
res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
});
router.patch('/comments/:commentId', requireWriter, async (req: AuthRequest, res: Response) => {
const id = Number(req.params.commentId); const status = req.body?.status;
if (!['open', 'resolved', 'confirmed'].includes(status)) { res.status(400).json({ error: '无效状态' }); return; }
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM work_comments wc JOIN notes n ON n.id = wc.note_id JOIN collections c ON c.id = n.collection_id WHERE wc.id = ?', [id]);
const context = await database.one<{ project_id: number; version_number: number; current_version: number; round_status: string; project_status: string; project_review_status: string }>('SELECT n.project_id,wc.version_number,n.version_number AS current_version,r.status AS round_status,p.status AS project_status,p.review_status AS project_review_status FROM work_comments wc JOIN notes n ON n.id=wc.note_id JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE wc.id = ?', [id]);
if (!context) { res.status(404).json({ error: '反馈不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该反馈' }); return; }
if (context.project_status !== 'active' || context.project_review_status === 'completed' || context.round_status !== 'reviewing' || Number(context.version_number) !== Number(context.current_version)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
await database.execute('UPDATE work_comments SET status = ? WHERE id = ?', [status, id]);
res.json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
});

View File

@@ -7,7 +7,7 @@ import { database } from '../database.js';
const router = Router();
async function imageProjectId(imageId: number): Promise<number | undefined> {
return (await database.one<{ project_id: number }>('SELECT c.project_id FROM images i JOIN notes n ON n.id = i.note_id JOIN collections c ON c.id = n.collection_id WHERE i.id = ?', [imageId]))?.project_id;
return (await database.one<{ project_id: number }>('SELECT n.project_id FROM images i JOIN notes n ON n.id = i.note_id WHERE i.id = ?', [imageId]))?.project_id;
}
router.get('/:imageId/annotations', requireWriter, async (req: AuthRequest, res: Response, next: NextFunction) => {
@@ -26,13 +26,14 @@ router.post('/:imageId/annotations', requireWriter, async (req: AuthRequest, res
const imageId = Number(req.params.imageId);
if (!Number.isFinite(imageId)) { res.status(400).json({ error: '无效的图片 ID' }); return; }
if (!await imagesRepository.findById(imageId)) { res.status(404).json({ error: '图片不存在' }); return; }
const projectId = await imageProjectId(imageId);
if (!projectId || !await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权操作该图片' }); return; }
const context = await database.one<{ project_id: number; review_round_id: number; active_round_id: number | null; round_status: string; project_review_status: string; project_status: string }>('SELECT n.project_id,v.review_round_id,n.active_round_id,r.status AS round_status,p.review_status AS project_review_status,p.status AS project_status FROM images i JOIN notes n ON n.id=i.note_id JOIN projects p ON p.id=n.project_id JOIN work_versions v ON v.note_id=i.note_id AND v.version_number=i.version_number JOIN review_rounds r ON r.id=v.review_round_id WHERE i.id=?', [imageId]);
if (!context || !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该图片' }); return; }
if (context.project_status !== 'active' || context.project_review_status === 'completed' || context.round_status !== 'reviewing' || Number(context.review_round_id) !== Number(context.active_round_id)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
const { x, y } = req.body ?? {};
const content = String(req.body?.content ?? '').trim();
if (typeof x !== 'number' || typeof y !== 'number' || x < 0 || x > 1 || y < 0 || y > 1) { res.status(400).json({ error: '批注坐标无效' }); return; }
if (!content) { res.status(400).json({ error: '批注内容不能为空' }); return; }
res.status(201).json(await annotationsRepository.create(imageId, { x, y, content, author_name: req.authUser?.display_name || 'API' }));
res.status(201).json(await annotationsRepository.create(imageId, { x, y, content, author_name: req.authUser?.display_name || 'API', author_role: 'operator' }));
} catch (error) { next(error); }
});

View File

@@ -4,10 +4,11 @@
import { Router, type Response, type NextFunction } from 'express';
import { upload } from '../upload.js';
import { notesService } from '../services/notesService.js';
import { recalculateCollectionStatus } from '../services/collectionsService.js';
import { recalculateProjectReviewStatus } from '../services/projectsService.js';
import { audit, canWriteProject, requireRole, requireWriter, type AuthRequest } from '../auth.js';
import { database, withTransaction } from '../database.js';
import fs from 'fs';
import type { TextAnnotation } from '../../shared/types.js';
const router = Router();
@@ -29,6 +30,15 @@ function parseImageUrls(value: unknown): { valid: boolean; urls: string[] } {
return { valid, urls };
}
type CandidateBody = { candidate_name?: unknown; title?: unknown; description?: unknown; tags?: unknown; images?: unknown; image_count?: unknown };
function parseCandidates(value: unknown): CandidateBody[] | null {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
return Array.isArray(parsed) ? parsed as CandidateBody[] : null;
} catch { return null; }
}
// GET /api/notes - 笔记列表
router.get('/', requireWriter, async (req: AuthRequest, res: Response) => {
const { sort, order, q, collectionId, status, tag, externalId } = req.query as {
@@ -69,18 +79,8 @@ router.get('/:noteId', requireWriter, async (req: AuthRequest, res: Response, ne
});
router.post('/:noteId/text-annotations', requireWriter, async (req: AuthRequest, res: Response) => {
const noteId = Number(req.params.noteId);
const versionNumber = Number(req.body?.version_number);
const target = req.body?.target;
const content = String(req.body?.content ?? '').trim();
if (!Number.isFinite(noteId) || !Number.isFinite(versionNumber) || !['title', 'description'].includes(target)) { res.status(400).json({ error: '批注目标无效' }); return; }
if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 11000 个字符' }); return; }
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id WHERE v.note_id=? AND v.version_number=?', [noteId, versionNumber]);
if (!context) { res.status(404).json({ error: '作品版本不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权批注该作品' }); return; }
const id = await database.insertId('INSERT INTO text_annotations (note_id, version_number, target, content, author_name) VALUES (?, ?, ?, ?, ?)', [noteId, versionNumber, target, content, req.authUser?.display_name || 'API']);
await audit(req, 'text_annotation.create', 'text_annotation', id, { noteId, versionNumber, target });
res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?', [id]));
res.setHeader('Deprecation', 'true');
res.status(410).json({ error: '该接口已停用,请使用 /api/works/:workId/text-annotations 并提交明确的文字选区' });
});
// POST /api/notes - 上传新笔记 (multipart/form-data)
@@ -113,12 +113,13 @@ router.post(
return;
}
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
const collection = await database.one<{ project_id: number }>('SELECT c.project_id FROM collections c WHERE c.id = ?', [collectionId]);
const collection = await database.one<{ project_id: number; project_status: string }>('SELECT c.project_id,p.status AS project_status FROM collections c JOIN projects p ON p.id=c.project_id WHERE c.id = ?', [collectionId]);
if (!collection || !await canWriteProject(req, collection.project_id)) {
files.forEach((file) => { try { fs.unlinkSync(file.path); } catch { /* uploaded file may already be gone */ } });
res.status(collection ? 403 : 404).json({ error: collection ? '无权向该作品交付集上传作品' : '作品交付集不存在' });
return;
}
if (collection.project_status !== 'active') { files.forEach((file) => { try { fs.unlinkSync(file.path); } catch { /* noop */ } }); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
if (externalId) {
const existing = await notesService.findByExternalId(collectionId, externalId);
if (existing) { res.status(200).json({ ...existing, idempotent: true }); return; }
@@ -156,9 +157,10 @@ router.post('/:noteId/versions', requireWriter, upload.array('images', 30), asyn
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
try {
const id = Number(req.params.noteId);
const context = await database.one<{ title: string; description: string; tags: string; project_id: number }>('SELECT n.title, n.description, n.tags, c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
const context = await database.one<{ title: string; description: string; tags: string; project_id: number; project_status: string }>('SELECT n.title,n.description,n.tags,n.project_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id = ?', [id]);
if (!context) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (context.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
const { valid: validImageUrls, urls: imageUrls } = parseImageUrls(req.body.images);
if (!validImageUrls) { res.status(400).json({ error: 'images 需要包含 130 个有效的 HTTP/HTTPS 图片 URL' }); return; }
if (!files.length && !imageUrls.length) { res.status(400).json({ error: '新版本至少需要一张图片' }); return; }
@@ -174,6 +176,53 @@ router.post('/:noteId/versions', requireWriter, upload.array('images', 30), asyn
} catch (error) { files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } }); next(error); }
});
router.post('/:noteId/review-rounds', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => {
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
const cleanupFiles = () => files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } });
try {
const id = Number(req.params.noteId);
const context = await database.one<{ project_id: number; project_status: string }>('SELECT n.project_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id=?', [id]);
if (!context) { cleanupFiles(); res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { cleanupFiles(); res.status(403).json({ error: '无权操作该作品' }); return; }
if (context.project_status !== 'active') { cleanupFiles(); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
const rawCandidates = parseCandidates(req.body?.candidates);
if (!rawCandidates || rawCandidates.length !== 1) { cleanupFiles(); res.status(400).json({ error: '每个验收轮次只能提交一个方案' }); return; }
const normalized = rawCandidates.map((candidate, index) => ({
candidate_name: String(candidate.candidate_name ?? `方案 ${String.fromCharCode(65 + index)}`).trim(),
title: String(candidate.title ?? '').trim(),
description: String(candidate.description ?? '').trim(),
tags: parseTags(candidate.tags),
image_count: Number(candidate.image_count ?? 0),
imageUrls: parseImageUrls(candidate.images),
}));
if (normalized.some((candidate) => !candidate.candidate_name || candidate.candidate_name.length > 30 || !candidate.title)) { cleanupFiles(); res.status(400).json({ error: '候选稿名称须为 130 个字符,标题不能为空' }); return; }
let note;
if (files.length) {
const expected = normalized.reduce((sum, candidate) => sum + candidate.image_count, 0);
if (expected !== files.length || normalized.some((candidate) => candidate.image_count < 1 || candidate.image_count > 30)) { cleanupFiles(); res.status(400).json({ error: '候选稿图片数量与上传文件不一致' }); return; }
let offset = 0;
const uploadCandidates = normalized.map((candidate) => {
const candidateFiles = files.slice(offset, offset + candidate.image_count); offset += candidate.image_count;
return { ...candidate, files: candidateFiles.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })) };
});
const only = uploadCandidates[0];
note = await notesService.createRound(id, { title: only.title, description: only.description, tags: only.tags, files: only.files }, req.authUser?.id);
} else {
const totalImages = normalized.reduce((sum, candidate) => sum + candidate.imageUrls.urls.length, 0);
if (totalImages > 30 || normalized.some((candidate) => !candidate.imageUrls.valid || candidate.imageUrls.urls.length < 1)) { cleanupFiles(); res.status(400).json({ error: '每个候选稿至少需要 1 个有效公开图片 URL本轮总计不超过 30 张' }); return; }
const only = normalized[0];
note = await notesService.createRoundFromUrls(id, { title: only.title, description: only.description, tags: only.tags, images: only.imageUrls.urls }, req.authUser?.id);
}
await audit(req, 'work.review_round_create', 'work', id, { candidateCount: 1, versionNumber: note.version_number, deprecatedRoute: true });
res.status(201).json(note);
} catch (error) {
cleanupFiles();
next(error);
}
});
router.patch('/:noteId/status', requireWriter, async (req: AuthRequest, res: Response) => {
const id = Number(req.params.noteId);
const status = req.body?.status;
@@ -181,8 +230,10 @@ router.patch('/:noteId/status', requireWriter, async (req: AuthRequest, res: Res
res.status(400).json({ error: '无效的验收状态' });
return;
}
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
const context = await database.one<{ project_id: number; review_status: string; project_status: string }>('SELECT n.project_id,n.review_status,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id = ?', [id]);
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (context && context.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
if (context?.review_status === 'approved') { res.status(409).json({ error: '已通过作品只能由组管理员填写原因后重新打开' }); return; }
if (!await notesService.setStatus(id, status)) {
res.status(404).json({ error: '作品不存在' });
return;
@@ -194,15 +245,20 @@ router.post('/:noteId/reopen', requireRole('platform_admin', 'group_admin'), asy
const id = Number(req.params.noteId);
const reason = String(req.body?.reason ?? '').trim();
if (!reason) { res.status(400).json({ error: '重新打开验收时必须填写原因' }); return; }
const note = await database.one<{ review_status: string; version_number: number; project_id: number }>('SELECT n.review_status, n.version_number, c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
const note = await database.one<{ review_status: string; version_number: number; project_id: number; collection_id: number; active_round_id: number | null; project_status: string }>('SELECT n.review_status,n.version_number,n.project_id,n.collection_id,n.active_round_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id = ?', [id]);
if (!note) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, note.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (note.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
if (note.review_status !== 'approved') { res.status(409).json({ error: '只有已通过作品可以重新打开' }); return; }
const actor = req.authUser!;
await withTransaction(async (tx) => {
await tx.execute("UPDATE notes SET review_status = 'pending' WHERE id = ?", [id]);
await tx.execute("UPDATE work_versions SET review_status = 'pending' WHERE note_id = ? AND version_number = ?", [id, note.version_number]);
await tx.execute("UPDATE notes SET approved_version_number = NULL WHERE id = ?", [id]);
await tx.execute("UPDATE work_versions SET review_status = 'pending', candidate_status = 'pending' WHERE note_id = ? AND version_number = ?", [id, note.version_number]);
if (note.active_round_id) await tx.execute("UPDATE review_rounds SET status = 'reviewing', selected_version_number = NULL, completed_at = NULL WHERE id = ?", [note.active_round_id]);
await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, reason, actor_name, actor_role) VALUES (?, ?, 'reopened', 'approved', 'pending', ?, ?, ?)", [id, note.version_number, reason, actor.display_name, actor.role]);
await recalculateCollectionStatus(Number(note.collection_id), tx);
await recalculateProjectReviewStatus(Number(note.project_id), tx);
});
await audit(req, 'work.reopen', 'work', id, { reason, versionNumber: note.version_number });
res.json({ success: true, status: 'pending' });
@@ -215,8 +271,9 @@ router.delete('/:noteId', requireWriter, async (req: AuthRequest, res: Response)
res.status(400).json({ error: '无效的笔记 ID' });
return;
}
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
const context = await database.one<{ project_id: number; project_status: string }>('SELECT n.project_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id = ?', [id]);
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (context && context.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
const ok = await notesService.remove(id);
if (!ok) {
res.status(404).json({ error: '笔记不存在' });

View File

@@ -0,0 +1,72 @@
import fs from 'node:fs';
import { Router, type NextFunction, type Response } from 'express';
import { canWriteProject, requireWriter, audit, type AuthRequest } from '../auth.js';
import { database } from '../database.js';
import { notesService } from '../services/notesService.js';
import { upload } from '../upload.js';
const router = Router({ mergeParams: true });
function parseTags(value: unknown): string[] {
if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean);
const raw = String(value ?? '').trim();
if (!raw) return [];
try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.map((tag) => String(tag).trim()).filter(Boolean); }
catch { /* comma-separated form input */ }
return raw.split(',').map((tag) => tag.trim()).filter(Boolean);
}
function parseImageUrls(value: unknown): string[] | null {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length < 1 || value.length > 30) return null;
const urls = value.map((item) => String(item).trim());
return urls.every((url) => {
try { return url.length <= 2048 && ['http:', 'https:'].includes(new URL(url).protocol); }
catch { return false; }
}) ? urls : null;
}
router.get('/', requireWriter, async (req: AuthRequest, res: Response) => {
const projectId = Number(req.params.projectId);
if (!Number.isInteger(projectId) || !await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该项目' }); return; }
const { sort, order, q, status, tag, externalId } = req.query as Record<string, string | undefined>;
res.json(await notesService.list({ projectId, sort, order, q, status: status as Parameters<typeof notesService.list>[0]['status'], tag, externalId }));
});
router.post('/', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => {
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
const cleanup = () => files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } });
try {
const projectId = Number(req.params.projectId);
if (!Number.isInteger(projectId) || !await canWriteProject(req, projectId)) { cleanup(); res.status(403).json({ error: '无权向该项目上传作品' }); return; }
const project = await database.one<{ status: string }>('SELECT status FROM projects WHERE id = ?', [projectId]);
if (!project) { cleanup(); res.status(404).json({ error: '项目不存在' }); return; }
if (project.status !== 'active') { cleanup(); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
const title = String(req.body?.title ?? '').trim();
const description = String(req.body?.description ?? '').trim();
const tags = parseTags(req.body?.tags);
const externalId = String(req.body?.externalId ?? req.body?.external_id ?? '').trim() || null;
const imageUrls = parseImageUrls(req.body?.images);
if (!title) { cleanup(); res.status(400).json({ error: '标题不能为空' }); return; }
if (externalId && (externalId.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(externalId))) { cleanup(); res.status(400).json({ error: 'externalId 格式无效' }); return; }
if (imageUrls === null || (!files.length && !imageUrls.length)) { cleanup(); res.status(400).json({ error: '请提供 130 张上传图片或公开图片 URL' }); return; }
if (externalId) {
const existing = await notesService.findByProjectExternalId(projectId, externalId);
if (existing) { cleanup(); res.json({ ...existing, idempotent: true }); return; }
}
let work;
try {
work = files.length
? await notesService.createInProject(projectId, title, description, files.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })), tags, externalId)
: await notesService.createInProjectFromUrls(projectId, title, description, imageUrls, tags, externalId);
} catch (error) {
const existing = externalId ? await notesService.findByProjectExternalId(projectId, externalId) : null;
if (existing) { cleanup(); res.json({ ...existing, idempotent: true }); return; }
throw error;
}
await audit(req, 'work.create', 'work', work.id, { projectId, imageCount: files.length || imageUrls.length, imageSource: files.length ? 'upload' : 'external_url' });
res.status(201).json(work);
} catch (error) { cleanup(); next(error); }
});
export default router;

View File

@@ -5,22 +5,29 @@ import type { Project, WorkCollection } from '../../shared/types.js';
const router = Router();
const reader = requireWriter;
type ProjectRow = Project & { customer_access_enabled: boolean | number; has_access_password: boolean | number; collection_count: number | string; work_count: number | string };
type ProjectRow = Project & { customer_access_enabled: boolean | number; has_access_password: boolean | number; collection_count: number | string; work_count: number | string; pending_count: number | string; changes_requested_count: number | string; approved_count: number | string };
type CollectionRow = WorkCollection & { work_count: number | string; approved_count: number | string };
function projectSelect(where: string) {
return `SELECT p.id, p.group_id, g.name AS group_name, p.name, p.slug, p.client_description, p.status, p.created_at,
return `SELECT p.id, p.group_id, g.name AS group_name, p.name, p.slug, p.client_description, p.status, p.review_status, p.review_completed_at, p.created_at,
p.customer_access_enabled, p.access_expires_at,
CASE WHEN p.access_password_hash != '' THEN 1 ELSE 0 END AS has_access_password,
COALESCE(cc.collection_count, 0) AS collection_count,
COALESCE(wc.work_count, 0) AS work_count
COALESCE(wc.work_count, 0) AS work_count,
COALESCE(wc.pending_count, 0) AS pending_count,
COALESCE(wc.changes_requested_count, 0) AS changes_requested_count,
COALESCE(wc.approved_count, 0) AS approved_count
FROM projects p
JOIN operation_groups g ON g.id = p.group_id
LEFT JOIN (SELECT project_id, COUNT(*) AS collection_count FROM collections GROUP BY project_id) cc ON cc.project_id = p.id
LEFT JOIN (SELECT c.project_id, COUNT(n.id) AS work_count FROM collections c LEFT JOIN notes n ON n.collection_id = c.id GROUP BY c.project_id) wc ON wc.project_id = p.id
LEFT JOIN (SELECT project_id, COUNT(*) AS work_count,
SUM(CASE WHEN review_status = 'pending' THEN 1 ELSE 0 END) AS pending_count,
SUM(CASE WHEN review_status = 'changes_requested' THEN 1 ELSE 0 END) AS changes_requested_count,
SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count
FROM notes GROUP BY project_id) wc ON wc.project_id = p.id
${where}`;
}
function projectJson(row: ProjectRow) { return { ...row, id: Number(row.id), group_id: Number(row.group_id), customer_access_enabled: Boolean(row.customer_access_enabled), has_access_password: Boolean(row.has_access_password), collection_count: Number(row.collection_count), work_count: Number(row.work_count) }; }
function projectJson(row: ProjectRow) { return { ...row, id: Number(row.id), group_id: Number(row.group_id), customer_access_enabled: Boolean(row.customer_access_enabled), has_access_password: Boolean(row.has_access_password), collection_count: Number(row.collection_count), work_count: Number(row.work_count), pending_count: Number(row.pending_count), changes_requested_count: Number(row.changes_requested_count), approved_count: Number(row.approved_count) }; }
function collectionJson(row: CollectionRow) { return { ...row, id: Number(row.id), project_id: Number(row.project_id), work_count: Number(row.work_count), approved_count: Number(row.approved_count) }; }
router.get('/', reader, async (req: AuthRequest, res: Response) => {
@@ -69,7 +76,7 @@ router.patch('/:projectId',requireWriter,async(req:AuthRequest,res:Response)=>{
router.get('/:projectId/collections',reader,async(req:AuthRequest,res:Response)=>{
const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权查看该项目'});return}
const rows=await database.all<CollectionRow>(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.project_id=? AND c.status!='archived' ORDER BY c.id DESC`,[id]);res.json(rows.map(collectionJson));
const rows=await database.all<CollectionRow>(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes WHERE review_status!='draft' GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.project_id=? AND c.status!='archived' ORDER BY c.id DESC`,[id]);res.json(rows.map(collectionJson));
});
router.patch('/:projectId/customer-access',requireWriter,async(req:AuthRequest,res:Response)=>{
@@ -83,12 +90,12 @@ router.patch('/:projectId/customer-access',requireWriter,async(req:AuthRequest,r
router.post('/:projectId/collections',requireWriter,async(req:AuthRequest,res:Response)=>{
const projectId=Number(req.params.projectId);if(!await canWriteProject(req,projectId)){res.status(403).json({error:'无权操作该项目'});return}const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'作品交付集名称不能为空'});return}
try{const id=await database.insertId('INSERT INTO collections (project_id, name, client_description) VALUES (?, ?, ?)',[projectId,name,description]);await audit(req,'collection.create','collection',id,{projectId,name});res.status(201).json(collectionJson((await database.one<CollectionRow>('SELECT c.*, 0 AS work_count, 0 AS approved_count FROM collections c WHERE c.id = ?',[id]))!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})}
try{const id=await database.insertId("INSERT INTO collections (project_id, name, client_description, status) VALUES (?, ?, ?, 'draft')",[projectId,name,description]);await audit(req,'collection.create','collection',id,{projectId,name});res.status(201).json(collectionJson((await database.one<CollectionRow>('SELECT c.*, 0 AS work_count, 0 AS approved_count FROM collections c WHERE c.id = ?',[id]))!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})}
});
router.patch('/:projectId/collections/:collectionId',requireWriter,async(req:AuthRequest,res:Response)=>{
const projectId=Number(req.params.projectId);if(!await canWriteProject(req,projectId)){res.status(403).json({error:'无权操作该项目'});return}const id=Number(req.params.collectionId);const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'作品交付集名称不能为空'});return}
try{if(!(await database.execute('UPDATE collections SET name = ?, client_description = ? WHERE id = ? AND project_id = ?',[name,description,id,projectId])).changes){res.status(404).json({error:'作品交付集不存在'});return}await audit(req,'collection.update','collection',id,{projectId,name});const row=await database.one<CollectionRow>(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.id=?`,[id]);res.json(collectionJson(row!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})}
try{if(!(await database.execute('UPDATE collections SET name = ?, client_description = ? WHERE id = ? AND project_id = ?',[name,description,id,projectId])).changes){res.status(404).json({error:'作品交付集不存在'});return}await audit(req,'collection.update','collection',id,{projectId,name});const row=await database.one<CollectionRow>(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes WHERE review_status!='draft' GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.id=?`,[id]);res.json(collectionJson(row!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})}
});
export default router;

View File

@@ -1,32 +1,176 @@
import { Router, type Response } from 'express';
import { database, withTransaction } from '../database.js';
import { database } from '../database.js';
import { createCustomerSession, customerSessionCookie, optionalCustomer, requireCustomerProject, type CustomerRequest } from '../customerAuth.js';
import { verifyPassword } from '../auth.js';
import { notesService } from '../services/notesService.js';
import { annotationsRepository } from '../repositories/annotationsRepository.js';
import type { TextAnnotation, WorkComment } from '../../shared/types.js';
import { decideRound, ReviewDecisionError } from '../services/reviewService.js';
import { addFeedbackReply, findFeedbackTarget, withdrawFeedback } from '../services/feedbackService.js';
import type { FeedbackType, TextAnnotation, WorkComment } from '../../shared/types.js';
const router=Router();router.use(optionalCustomer);
type ProjectAccess={id:number;name:string;slug:string;client_description:string;status:string;customer_access_enabled:boolean|number;access_password_hash:string;access_expires_at:string|Date|null};
const projectBySlug=(slug:string)=>database.one<ProjectAccess>(`SELECT id,name,slug,client_description,status,customer_access_enabled,access_password_hash,access_expires_at FROM projects WHERE slug=?`,[slug]);
const expired=(value:string|Date|null)=>Boolean(value&&new Date(value).getTime()<=Date.now());
const router = Router();
router.use(optionalCustomer);
router.get('/:slug/access',async(req:CustomerRequest,res:Response)=>{const project=await projectBySlug(req.params.slug);if(!project||project.status==='archived'){res.status(404).json({error:'项目不存在'});return}res.json({project_name:project.name,client_description:project.client_description,enabled:Boolean(project.customer_access_enabled),expired:expired(project.access_expires_at),authenticated:Boolean(req.customer?.project_id===Number(project.id)),reviewer_name:req.customer?.project_id===Number(project.id)?req.customer.reviewer_name:null})});
type ProjectAccess = {
id: number; name: string; slug: string; client_description: string; status: string; review_status: string;
customer_access_enabled: boolean | number; access_password_hash: string; access_expires_at: string | Date | null;
};
router.post('/:slug/login',async(req:CustomerRequest,res:Response)=>{const project=await projectBySlug(req.params.slug);const reviewerName=String(req.body?.reviewer_name??'').trim();const password=String(req.body?.password??'');if(!project||project.status==='archived'){res.status(404).json({error:'项目不存在'});return}if(!project.customer_access_enabled){res.status(403).json({error:'该项目暂未开放客户访问'});return}if(expired(project.access_expires_at)){res.status(403).json({error:'项目访问链接已到期'});return}if(reviewerName.length<2||reviewerName.length>30){res.status(400).json({error:'请填写 230 个字符的姓名'});return}if(!project.access_password_hash||!verifyPassword(password,project.access_password_hash)){res.status(401).json({error:'访问密码错误'});return}const token=await createCustomerSession(Number(project.id),reviewerName);res.setHeader('Set-Cookie',customerSessionCookie(token));res.json({success:true,reviewer_name:reviewerName})});
const projectBySlug = (slug: string) => database.one<ProjectAccess>(
'SELECT id,name,slug,client_description,status,review_status,customer_access_enabled,access_password_hash,access_expires_at FROM projects WHERE slug=?',
[slug],
);
const expired = (value: string | Date | null) => Boolean(value && new Date(value).getTime() <= Date.now());
const feedbackType = (value: string): FeedbackType | null => ['image_annotation', 'text_annotation', 'comment'].includes(value) ? value as FeedbackType : null;
const storedTagsText = (value: string) => { try { const parsed = JSON.parse(value || '[]'); return Array.isArray(parsed) ? parsed.map(String).join(' ') : ''; } catch { return ''; } };
router.get('/:slug/project',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const collections=await database.all<Record<string,unknown>>(`SELECT c.id,c.project_id,c.name,c.client_description,c.status,c.created_at,COUNT(n.id) AS work_count,COUNT(CASE WHEN n.review_status='approved' THEN 1 END) AS approved_count FROM collections c LEFT JOIN notes n ON n.collection_id=c.id AND n.review_status!='draft' WHERE c.project_id=? AND c.status IN ('reviewing','completed') GROUP BY c.id,c.project_id,c.name,c.client_description,c.status,c.created_at ORDER BY c.id DESC`,[project.id]);res.json({project:{id:Number(project.id),name:project.name,slug:project.slug,client_description:project.client_description,status:project.status},collections:collections.map((item)=>({...item,id:Number(item.id),project_id:Number(item.project_id),work_count:Number(item.work_count),approved_count:Number(item.approved_count)})),reviewer_name:req.customer!.reviewer_name})});
router.get('/:slug/access', async (req: CustomerRequest, res: Response) => {
const project = await projectBySlug(req.params.slug);
if (!project || project.status === 'archived') { res.status(404).json({ error: '项目不存在' }); return; }
res.json({ project_name: project.name, client_description: project.client_description, enabled: Boolean(project.customer_access_enabled), expired: expired(project.access_expires_at), authenticated: Boolean(req.customer?.project_id === Number(project.id)), reviewer_name: req.customer?.project_id === Number(project.id) ? req.customer.reviewer_name : null });
});
router.get('/:slug/collections/:collectionId/works',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const collectionId=Number(req.params.collectionId);const collection=await database.one<Record<string,unknown>>("SELECT * FROM collections WHERE id=? AND project_id=? AND status IN ('reviewing','completed')",[collectionId,project.id]);if(!collection){res.status(404).json({error:'作品交付集不存在或尚未发布'});return}const works=(await notesService.list({collectionId})).filter((work)=>work.review_status!=='draft');res.json({collection,works})});
router.post('/:slug/login', async (req: CustomerRequest, res: Response) => {
const project = await projectBySlug(req.params.slug);
const reviewerName = String(req.body?.reviewer_name ?? '').trim();
const password = String(req.body?.password ?? '');
if (!project || project.status === 'archived') { res.status(404).json({ error: '项目不存在' }); return; }
if (!project.customer_access_enabled) { res.status(403).json({ error: '该项目暂未开放客户访问' }); return; }
if (expired(project.access_expires_at)) { res.status(403).json({ error: '项目访问链接已到期' }); return; }
if (reviewerName.length < 2 || reviewerName.length > 30) { res.status(400).json({ error: '请填写 230 个字符的姓名' }); return; }
if (!project.access_password_hash || !verifyPassword(password, project.access_password_hash)) { res.status(401).json({ error: '访问密码错误' }); return; }
const token = await createCustomerSession(Number(project.id), reviewerName);
res.setHeader('Set-Cookie', customerSessionCookie(token));
res.json({ success: true, reviewer_name: reviewerName });
});
router.get('/:slug/works/:noteId',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const belongs=await database.one<{review_status:string}>('SELECT n.review_status FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=? AND c.project_id=?',[noteId,project.id]);if(!belongs||belongs.review_status==='draft'){res.status(404).json({error:'作品不存在或尚未提交'});return}const version=req.query.version?Number(req.query.version):undefined;const detail=await notesService.getDetail(noteId,version);if(!detail){res.status(404).json({error:'作品版本不存在'});return}res.json(detail)});
router.get('/:slug/project', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!;
const works = (await notesService.list({ projectId: Number(project.id) })).filter((work) => work.review_status !== 'draft');
res.json({
project: { id: Number(project.id), name: project.name, slug: project.slug, client_description: project.client_description, status: project.status, review_status: project.review_status },
works,
reviewer_name: req.customer!.reviewer_name,
});
});
router.post('/:slug/works/:noteId/comments',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const content=String(req.body?.content??'').trim();const belongs=await database.one('SELECT n.id FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=? AND c.project_id=? AND n.review_status!=?',[noteId,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品不存在'});return}if(!content||content.length>2000){res.status(400).json({error:'反馈内容须为 12000 个字符'});return}const id=await database.insertId("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id=?',[id]))});
router.get('/:slug/collections/:collectionId/works', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!;
const works = (await notesService.list({ projectId: Number(project.id) })).filter((work) => work.review_status !== 'draft');
res.setHeader('Deprecation', 'true');
res.json({ redirect_to: `/review/${project.slug}`, works });
});
router.post('/:slug/works/:noteId/text-annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const versionNumber=Number(req.body?.version_number);const target=req.body?.target;const content=String(req.body?.content??'').trim();if(!Number.isFinite(versionNumber)||!['title','description'].includes(target)){res.status(400).json({error:'批注目标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 11000 个字符'});return}const belongs=await database.one('SELECT v.id FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id WHERE v.note_id=? AND v.version_number=? AND c.project_id=? AND n.review_status!=?',[noteId,versionNumber,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品版本不存在'});return}const id=await database.insertId('INSERT INTO text_annotations (note_id,version_number,target,content,author_name) VALUES (?,?,?,?,?)',[noteId,versionNumber,target,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?',[id]))});
router.get('/:slug/works/:workId', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!;
const workId = Number(req.params.workId);
const belongs = await database.one<{ review_status: string }>('SELECT review_status FROM notes WHERE id=? AND project_id=?', [workId, project.id]);
if (!belongs || belongs.review_status === 'draft') { res.status(404).json({ error: '作品不存在或尚未提交' }); return; }
const round = req.query.round ? Number(req.query.round) : undefined;
const version = req.query.version ? Number(req.query.version) : undefined;
const detail = await notesService.getDetail(workId, version, round);
if (!detail) { res.status(404).json({ error: '验收轮次不存在' }); return; }
res.json(detail);
});
router.post('/:slug/images/:imageId/annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const imageId=Number(req.params.imageId);const{x,y}=req.body??{};const content=String(req.body?.content??'').trim();const belongs=await database.one(`SELECT i.id FROM images i JOIN notes n ON n.id=i.note_id JOIN collections c ON c.id=n.collection_id WHERE i.id=? AND c.project_id=? AND n.review_status!='draft'`,[imageId,project.id]);if(!belongs){res.status(404).json({error:'图片不存在'});return}if(typeof x!=='number'||typeof y!=='number'||x<0||x>1||y<0||y>1){res.status(400).json({error:'批注坐标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 11000 个字符'});return}res.status(201).json(await annotationsRepository.create(imageId,{x,y,content,author_name:req.customer!.reviewer_name}))});
router.get('/:slug/works/:workId/annotations', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!;
const workId = Number(req.params.workId);
if (!await database.one('SELECT id FROM notes WHERE id=? AND project_id=? AND review_status!=?', [workId, project.id, 'draft'])) { res.status(404).json({ error: '作品不存在' }); return; }
res.json(await notesService.getFeedback(workId));
});
router.post('/:slug/works/:noteId/decision',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const decision=req.body?.decision;const reason=String(req.body?.reason??'').trim();if(!['approved','changes_requested'].includes(decision)){res.status(400).json({error:'验收决定无效'});return}if(decision==='changes_requested'&&!reason){res.status(400).json({error:'要求修改时必须填写原因'});return}const current=await database.one<{version_number:number;review_status:string}>(`SELECT n.version_number,n.review_status FROM notes n WHERE n.id=? AND n.review_status!='draft' AND n.collection_id IN (SELECT id FROM collections WHERE project_id=?)`,[noteId,project.id]);if(!current){res.status(404).json({error:'作品不存在或尚未提交'});return}await withTransaction(async(tx)=>{await tx.execute('UPDATE notes SET review_status=? WHERE id=?',[decision,noteId]);await tx.execute('UPDATE work_versions SET review_status=? WHERE note_id=? AND version_number=?',[decision,noteId,current.version_number]);await tx.execute('INSERT INTO review_events (note_id,version_number,event_type,from_status,to_status,reason,actor_name,actor_role) VALUES (?,?,?,?,?,?,?,?)',[noteId,current.version_number,decision,current.review_status,decision,reason,req.customer!.reviewer_name,'client']);if(reason)await tx.execute("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,reason,req.customer!.reviewer_name])});res.json({success:true,status:decision})});
router.post('/:slug/works/:workId/comments', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!;
const workId = Number(req.params.workId);
const content = String(req.body?.content ?? '').trim();
const work = await database.one<{ version_number: number; round_status: string }>('SELECT n.version_number,r.status AS round_status FROM notes n JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=? AND n.project_id=? AND n.review_status!=?', [workId, project.id, 'draft']);
if (!work) { res.status(404).json({ error: '作品不存在' }); return; }
if (project.status !== 'active' || project.review_status === 'completed' || work.round_status !== 'reviewing') { res.status(409).json({ error: '当前轮次、已关闭或已完成项目为只读状态' }); return; }
if (!content || content.length > 2000) { res.status(400).json({ error: '反馈内容须为 12000 个字符' }); return; }
const id = await database.insertId("INSERT INTO work_comments (note_id,version_number,content,author_name,author_role) VALUES (?,?,?,?,'client')", [workId, work.version_number, content, req.customer!.reviewer_name]);
res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id=?', [id]));
});
router.post('/:slug/works/:workId/text-annotations', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!;
const workId = Number(req.params.workId);
const roundNumber = Number(req.body?.round_number);
const target = req.body?.target as 'title' | 'description' | 'tags';
const startOffset = Number(req.body?.start_offset);
const endOffset = Number(req.body?.end_offset);
const selectedText = String(req.body?.selected_text ?? '');
const content = String(req.body?.content ?? '').trim();
const version = await database.one<{ version_number: number; title: string; description: string; tags: string; review_round_id: number; active_round_id: number | null; round_status: string }>(`SELECT v.version_number,v.title,v.description,v.tags,v.review_round_id,n.active_round_id,r.status AS round_status
FROM work_versions v JOIN review_rounds r ON r.id=v.review_round_id JOIN notes n ON n.id=v.note_id
WHERE v.note_id=? AND r.round_number=? AND n.project_id=?`, [workId, roundNumber, project.id]);
if (!version) { res.status(404).json({ error: '验收轮次不存在' }); return; }
if (project.status !== 'active' || project.review_status === 'completed' || version.round_status !== 'reviewing' || Number(version.review_round_id) !== Number(version.active_round_id)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
const source = target === 'title' ? version.title : target === 'description' ? version.description : target === 'tags' ? storedTagsText(version.tags) : '';
if (!source || !Number.isInteger(startOffset) || !Number.isInteger(endOffset) || startOffset < 0 || endOffset <= startOffset || endOffset > source.length || source.slice(startOffset, endOffset) !== selectedText) { res.status(400).json({ error: '请选择标题或正文中的有效文字区域' }); return; }
if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 11000 个字符' }); return; }
const id = await database.insertId(`INSERT INTO text_annotations (note_id,version_number,target,start_offset,end_offset,selected_text,prefix_text,suffix_text,content,author_name,author_role)
VALUES (?,?,?,?,?,?,?,?,?,?,'client')`, [workId, version.version_number, target, startOffset, endOffset, selectedText, source.slice(Math.max(0, startOffset - 24), startOffset), source.slice(endOffset, endOffset + 24), content, req.customer!.reviewer_name]);
res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?', [id]));
});
router.post('/:slug/images/:imageId/annotations', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!;
const imageId = Number(req.params.imageId);
const { x, y } = req.body ?? {};
const content = String(req.body?.content ?? '').trim();
const context = await database.one<{ review_round_id: number; active_round_id: number | null; round_status: string }>(`SELECT v.review_round_id,n.active_round_id,r.status AS round_status FROM images i JOIN notes n ON n.id=i.note_id
JOIN work_versions v ON v.note_id=i.note_id AND v.version_number=i.version_number JOIN review_rounds r ON r.id=v.review_round_id
WHERE i.id=? AND n.project_id=? AND n.review_status!='draft'`, [imageId, project.id]);
if (!context) { res.status(404).json({ error: '图片不存在' }); return; }
if (project.status !== 'active' || project.review_status === 'completed' || context.round_status !== 'reviewing' || Number(context.review_round_id) !== Number(context.active_round_id)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
if (typeof x !== 'number' || typeof y !== 'number' || x < 0 || x > 1 || y < 0 || y > 1) { res.status(400).json({ error: '批注坐标无效' }); return; }
if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 11000 个字符' }); return; }
res.status(201).json(await annotationsRepository.create(imageId, { x, y, content, author_name: req.customer!.reviewer_name, author_role: 'client' }));
});
router.post('/:slug/works/:workId/feedback/:type/:feedbackId/replies', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!; const workId = Number(req.params.workId);
const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId); const content = String(req.body?.content ?? '').trim();
if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; }
if (!await database.one('SELECT id FROM notes WHERE id=? AND project_id=?', [workId, project.id])) { res.status(404).json({ error: '作品不存在' }); return; }
const target = await findFeedbackTarget(workId, type, feedbackId);
const state = await database.one<{ version_number: number; round_status: string }>('SELECT n.version_number,r.status AS round_status FROM notes n JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
if (!target) { res.status(404).json({ error: '反馈不存在' }); return; }
if (project.status !== 'active' || project.review_status === 'completed' || state?.round_status !== 'reviewing' || Number(target.version_number) !== Number(state?.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
if (!content || content.length > 1000) { res.status(400).json({ error: '回复内容须为 11000 个字符' }); return; }
res.status(201).json(await addFeedbackReply(target, type, feedbackId, content, req.customer!.reviewer_name, 'client'));
});
router.post('/:slug/works/:workId/feedback/:type/:feedbackId/withdraw', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!; const workId = Number(req.params.workId);
const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId);
if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; }
if (!await database.one('SELECT id FROM notes WHERE id=? AND project_id=?', [workId, project.id])) { res.status(404).json({ error: '作品不存在' }); return; }
const target = await findFeedbackTarget(workId, type, feedbackId);
if (!target) { res.status(404).json({ error: '反馈不存在' }); return; }
const state = await database.one<{ version_number: number; round_status: string }>('SELECT n.version_number,r.status AS round_status FROM notes n JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
if (project.status !== 'active' || project.review_status === 'completed' || state?.round_status !== 'reviewing' || Number(target.version_number) !== Number(state?.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
if (target.withdrawn_at) { res.json({ success: true }); return; }
if (target.author_role !== 'client' || target.author_name !== req.customer!.reviewer_name) { res.status(403).json({ error: '只能撤回自己提交的反馈' }); return; }
await withdrawFeedback(type, feedbackId); res.json({ success: true });
});
router.post('/:slug/works/:workId/decision', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!;
const workId = Number(req.params.workId);
const roundNumber = Number(req.body?.round_number);
const legacyVersion = Number(req.body?.version_number);
const round = Number.isInteger(roundNumber) ? await database.one<{ version_number: number }>('SELECT v.version_number FROM review_rounds r JOIN work_versions v ON v.review_round_id=r.id WHERE r.note_id=? AND r.round_number=?', [workId, roundNumber]) : undefined;
const versionNumber = round ? Number(round.version_number) : legacyVersion;
const decision = req.body?.decision;
const reason = String(req.body?.reason ?? '').trim();
if (!Number.isInteger(versionNumber) || versionNumber < 1) { res.status(400).json({ error: '验收决定必须明确指定轮次' }); return; }
if (!['approved', 'changes_requested'].includes(decision)) { res.status(400).json({ error: '验收决定无效' }); return; }
if (reason.length > 2000) { res.status(400).json({ error: '验收原因不能超过 2000 个字符' }); return; }
if (decision === 'changes_requested' && !reason) { res.status(400).json({ error: '要求修改时必须填写原因' }); return; }
try { res.json(await decideRound({ noteId: workId, versionNumber, projectId: Number(project.id), decision, reason, actorName: req.customer!.reviewer_name, actorRole: 'client' })); }
catch (error) { if (error instanceof ReviewDecisionError) { res.status(error.statusCode).json({ error: error.message }); return; } throw error; }
});
export default router;

View File

@@ -2,7 +2,7 @@ import { Router, type Response } from 'express';
import { audit, requireRole, type AuthRequest } from '../auth.js';
import { encryptSecret } from '../configCrypto.js';
import { database, withTransaction } from '../database.js';
import { testStorageConfig, type StorageConfigRecord } from '../storage.js';
import { assertPublicHttpUrl, testStorageConfig, type StorageConfigRecord } from '../storage.js';
const router=Router();
type PublicRow=Record<string,unknown>&{id:number|string;has_credentials:boolean|number};
@@ -10,7 +10,7 @@ async function publicRows(){return(await database.all<PublicRow>(`SELECT s.id,s.
function validUrl(value:string){if(!value)return true;try{return['http:','https:'].includes(new URL(value).protocol)}catch{return false}}
router.get('/',requireRole('platform_admin'),async(_req,res)=>res.json(await publicRows()));
router.post('/',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const region=String(req.body?.region??'').trim().toLowerCase();const bucket=String(req.body?.bucket??'').trim().toLowerCase();const publicBaseUrl=String(req.body?.public_base_url??'').trim();const cdnDomain=String(req.body?.cdn_domain??'').trim();const pathPrefix=String(req.body?.path_prefix??'delivery-desk').trim().replace(/^\/+|\/+$/g,'');const secretId=String(req.body?.secret_id??'').trim();const secretKey=String(req.body?.secret_key??'').trim();if(!/^[a-z0-9-]+$/.test(region)){res.status(400).json({error:'COS 地域格式不正确,例如 ap-guangzhou'});return}if(!/^[a-z0-9][a-z0-9-]+-\d+$/.test(bucket)){res.status(400).json({error:'存储桶名称需要包含 APPID'});return}if(!secretId||!secretKey){res.status(400).json({error:'SecretId 和 SecretKey 均为必填项'});return}if(!validUrl(publicBaseUrl)||!validUrl(cdnDomain)){res.status(400).json({error:'访问域名必须是有效的 HTTP 或 HTTPS 地址'});return}if(pathPrefix.includes('..')||pathPrefix.startsWith('/')){res.status(400).json({error:'文件路径前缀格式不正确'});return}const id=await database.insertId('INSERT INTO storage_configs (region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted,created_by) VALUES (?,?,?,?,?,?,?,?)',[region,bucket,publicBaseUrl,cdnDomain,pathPrefix,encryptSecret(secretId),encryptSecret(secretKey),req.authUser?.id]);await audit(req,'storage_config.create','storage_config',id,{region,bucket,pathPrefix});res.status(201).json((await publicRows()).find((item)=>item.id===id))});
router.post('/',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const region=String(req.body?.region??'').trim().toLowerCase();const bucket=String(req.body?.bucket??'').trim().toLowerCase();const publicBaseUrl=String(req.body?.public_base_url??'').trim();const cdnDomain=String(req.body?.cdn_domain??'').trim();const pathPrefix=String(req.body?.path_prefix??'delivery-desk').trim().replace(/^\/+|\/+$/g,'');const secretId=String(req.body?.secret_id??'').trim();const secretKey=String(req.body?.secret_key??'').trim();if(!/^[a-z0-9-]+$/.test(region)){res.status(400).json({error:'COS 地域格式不正确,例如 ap-guangzhou'});return}if(!/^[a-z0-9][a-z0-9-]+-\d+$/.test(bucket)){res.status(400).json({error:'存储桶名称需要包含 APPID'});return}if(!secretId||!secretKey){res.status(400).json({error:'SecretId 和 SecretKey 均为必填项'});return}if(!validUrl(publicBaseUrl)||!validUrl(cdnDomain)){res.status(400).json({error:'访问域名必须是有效的 HTTP 或 HTTPS 地址'});return}await assertPublicHttpUrl(publicBaseUrl||`https://${bucket}.cos.${region}.myqcloud.com`);if(cdnDomain)await assertPublicHttpUrl(cdnDomain);if(pathPrefix.includes('..')||pathPrefix.startsWith('/')){res.status(400).json({error:'文件路径前缀格式不正确'});return}const id=await database.insertId('INSERT INTO storage_configs (region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted,created_by) VALUES (?,?,?,?,?,?,?,?)',[region,bucket,publicBaseUrl,cdnDomain,pathPrefix,encryptSecret(secretId),encryptSecret(secretKey),req.authUser?.id]);await audit(req,'storage_config.create','storage_config',id,{region,bucket,pathPrefix});res.status(201).json((await publicRows()).find((item)=>item.id===id))});
router.post('/:configId/test',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const id=Number(req.params.configId);const config=await database.one<StorageConfigRecord>('SELECT id,region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted FROM storage_configs WHERE id=? AND status!=?',[id,'archived']);if(!config){res.status(404).json({error:'存储配置不存在'});return}try{const message=await testStorageConfig(config);await database.execute("UPDATE storage_configs SET test_status='passed',test_message=?,last_tested_at=? WHERE id=?",[message,new Date().toISOString(),id]);await audit(req,'storage_config.test_passed','storage_config',id,{bucket:config.bucket});res.json({success:true,test_status:'passed',test_message:message})}catch(error){const message=error instanceof Error?error.message:'COS 连接测试失败';await database.execute("UPDATE storage_configs SET test_status='failed',test_message=?,last_tested_at=? WHERE id=?",[message,new Date().toISOString(),id]);await audit(req,'storage_config.test_failed','storage_config',id,{bucket:config.bucket,message});res.status(400).json({error:message,test_status:'failed'})}});
router.post('/:configId/activate',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const id=Number(req.params.configId);const config=await database.one<{id:number;bucket:string;test_status:string}>('SELECT id,bucket,test_status FROM storage_configs WHERE id=? AND status=?',[id,'draft']);if(!config){res.status(404).json({error:'待启用的存储配置不存在'});return}if(config.test_status!=='passed'){res.status(409).json({error:'连接测试通过后才能启用该配置'});return}await withTransaction(async(tx)=>{await tx.execute("UPDATE storage_configs SET status='archived' WHERE status='active'");await tx.execute("UPDATE storage_configs SET status='active',activated_at=? WHERE id=?",[new Date().toISOString(),id])});await audit(req,'storage_config.activate','storage_config',id,{bucket:config.bucket});res.json({success:true})});
export default router;

182
api/routes/works.ts Normal file
View File

@@ -0,0 +1,182 @@
import fs from 'node:fs';
import { Router, type NextFunction, type Response } from 'express';
import { audit, canWriteProject, requireWriter, type AuthRequest } from '../auth.js';
import { database } from '../database.js';
import { notesService } from '../services/notesService.js';
import { addFeedbackReply, findFeedbackTarget, withdrawFeedback } from '../services/feedbackService.js';
import { upload } from '../upload.js';
import type { FeedbackType, TextAnnotation, WorkComment } from '../../shared/types.js';
const router = Router();
function parseTags(value: unknown): string[] {
if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean);
const raw = String(value ?? '').trim();
if (!raw) return [];
try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.map((tag) => String(tag).trim()).filter(Boolean); }
catch { /* comma-separated form input */ }
return raw.split(',').map((tag) => tag.trim()).filter(Boolean);
}
function parseImageUrls(value: unknown): string[] | null {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length < 1 || value.length > 30) return null;
const urls = value.map((item) => String(item).trim());
return urls.every((url) => { try { return url.length <= 2048 && ['http:', 'https:'].includes(new URL(url).protocol); } catch { return false; } }) ? urls : null;
}
async function workProjectId(workId: number): Promise<number | undefined> {
return (await database.one<{ project_id: number }>('SELECT project_id FROM notes WHERE id = ?', [workId]))?.project_id;
}
const feedbackType = (value: string): FeedbackType | null => ['image_annotation', 'text_annotation', 'comment'].includes(value) ? value as FeedbackType : null;
router.get('/:workId', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId);
const projectId = await workProjectId(workId);
if (!projectId) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该作品' }); return; }
const round = req.query.round ? Number(req.query.round) : undefined;
const detail = await notesService.getDetail(workId, undefined, round);
if (!detail) { res.status(404).json({ error: '验收轮次不存在' }); return; }
res.json(detail);
});
router.get('/:workId/annotations', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId);
const projectId = await workProjectId(workId);
if (!projectId) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该作品反馈' }); return; }
res.json(await notesService.getFeedback(workId));
});
router.get('/:workId/optimization-context', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId);
const roundNumber = Number(req.query.round);
const includeHistory = req.query.include_history === 'true';
if (!Number.isInteger(roundNumber) || roundNumber < 1) { res.status(400).json({ error: '请指定有效的验收轮次' }); return; }
const projectId = await workProjectId(workId);
if (!projectId) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该作品反馈' }); return; }
const [detail, feedback] = await Promise.all([
notesService.getDetail(workId, undefined, roundNumber),
notesService.getFeedback(workId),
]);
const round = feedback?.rounds.find((item) => item.round_number === roundNumber);
if (!detail || !round) { res.status(404).json({ error: '验收轮次不存在' }); return; }
const visible = (item: { status: string; withdrawn_at: string | null }) => includeHistory || (item.status === 'open' && !item.withdrawn_at);
const repliesFor = (type: FeedbackType, id: number) => round.feedback_replies.filter((reply) => reply.feedback_type === type && reply.feedback_id === id && (includeHistory || !reply.withdrawn_at));
const withReplies = <T extends { id: number }>(type: FeedbackType, item: T) => ({ ...item, replies: repliesFor(type, item.id) });
res.json({
project: detail.project,
work_id: workId,
work_label: `Work ${String(workId).padStart(3, '0')}`,
round_number: roundNumber,
version_number: round.version_number,
content: {
title: detail.title,
description: detail.description,
tags: detail.tags,
images: detail.images.map(({ id, url, width, height, order_index }) => ({ image_id: id, url, width, height, order_index })),
},
feedback: {
image_annotations: round.image_annotations.filter(visible).map((item) => withReplies('image_annotation', item)),
text_annotations: round.text_annotations.filter(visible).map((item) => withReplies('text_annotation', item)),
general_comments: round.comments.filter(visible).map((item) => withReplies('comment', item)),
},
});
});
router.post('/:workId/rounds', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => {
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
const cleanup = () => files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } });
try {
const workId = Number(req.params.workId);
const projectId = await workProjectId(workId);
if (!projectId) { cleanup(); res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, projectId)) { cleanup(); res.status(403).json({ error: '无权操作该作品' }); return; }
const projectState = await database.one<{ status: string }>('SELECT status FROM projects WHERE id=?', [projectId]);
if (!projectState || projectState.status !== 'active') { cleanup(); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
const current = await notesService.getDetail(workId);
if (!current) { cleanup(); res.status(404).json({ error: '作品不存在' }); return; }
const title = String(req.body?.title ?? current.title).trim();
const description = String(req.body?.description ?? current.description).trim();
const tags = parseTags(req.body?.tags ?? current.tags);
const imageUrls = parseImageUrls(req.body?.images);
if (!title) { cleanup(); res.status(400).json({ error: '标题不能为空' }); return; }
if (imageUrls === null || (!files.length && !imageUrls.length)) { cleanup(); res.status(400).json({ error: '每轮必须提供 130 张图片' }); return; }
const work = files.length
? await notesService.createRound(workId, { title, description, tags, files: files.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })) }, req.authUser?.id)
: await notesService.createRoundFromUrls(workId, { title, description, tags, images: imageUrls }, req.authUser?.id);
await audit(req, 'work.round_create', 'work', workId, { roundNumber: work.version_number, imageCount: files.length || imageUrls.length });
res.status(201).json(work);
} catch (error) { cleanup(); next(error); }
});
router.post('/:workId/text-annotations', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId);
const roundNumber = Number(req.body?.round_number);
const target = req.body?.target as 'title' | 'description' | 'tags';
const startOffset = Number(req.body?.start_offset);
const endOffset = Number(req.body?.end_offset);
const selectedText = String(req.body?.selected_text ?? '');
const content = String(req.body?.content ?? '').trim();
const version = await database.one<{ version_number: number; title: string; description: string; tags: string; review_round_id: number; active_round_id: number | null; round_status: string; project_id: number; project_review_status: string; project_status: string }>(`SELECT v.version_number,v.title,v.description,v.tags,v.review_round_id,n.active_round_id,n.project_id,r.status AS round_status,p.review_status AS project_review_status,p.status AS project_status
FROM work_versions v JOIN review_rounds r ON r.id=v.review_round_id JOIN notes n ON n.id=v.note_id JOIN projects p ON p.id=n.project_id
WHERE v.note_id=? AND r.round_number=?`, [workId, roundNumber]);
if (!version) { res.status(404).json({ error: '验收轮次不存在' }); return; }
if (!await canWriteProject(req, Number(version.project_id))) { res.status(403).json({ error: '无权批注该作品' }); return; }
if (version.project_status !== 'active' || version.project_review_status === 'completed' || version.round_status !== 'reviewing' || Number(version.review_round_id) !== Number(version.active_round_id)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
const source = target === 'title' ? version.title : target === 'description' ? version.description : target === 'tags' ? parseTags(version.tags).join(' ') : '';
if (!source || !Number.isInteger(startOffset) || !Number.isInteger(endOffset) || startOffset < 0 || endOffset <= startOffset || endOffset > source.length || source.slice(startOffset, endOffset) !== selectedText) { res.status(400).json({ error: '请选择标题或正文中的有效文字区域' }); return; }
if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 11000 个字符' }); return; }
const prefix = source.slice(Math.max(0, startOffset - 24), startOffset);
const suffix = source.slice(endOffset, endOffset + 24);
const id = await database.insertId(`INSERT INTO text_annotations (note_id,version_number,target,start_offset,end_offset,selected_text,prefix_text,suffix_text,content,author_name,author_role)
VALUES (?,?,?,?,?,?,?,?,?,?, 'operator')`, [workId, version.version_number, target, startOffset, endOffset, selectedText, prefix, suffix, content, req.authUser?.display_name || 'API']);
await audit(req, 'text_annotation.create', 'text_annotation', id, { workId, roundNumber, target, startOffset, endOffset });
res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id = ?', [id]));
});
router.post('/:workId/comments', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId);
const content = String(req.body?.content ?? '').trim();
const context = await database.one<{ project_id: number; version_number: number; review_status: string; project_status: string; round_status: string }>('SELECT n.project_id,n.version_number,p.review_status,p.status AS project_status,r.status AS round_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
if (!context) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, Number(context.project_id))) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (context.project_status !== 'active' || context.review_status === 'completed' || context.round_status !== 'reviewing') { res.status(409).json({ error: '当前轮次、已关闭或已完成项目为只读状态' }); return; }
if (!content || content.length > 2000) { res.status(400).json({ error: '反馈内容须为 12000 个字符' }); return; }
const id = await database.insertId("INSERT INTO work_comments (note_id,version_number,content,author_name,author_role) VALUES (?,?,?,?, 'operator')", [workId, context.version_number, content, req.authUser?.display_name || 'API']);
res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
});
router.post('/:workId/feedback/:type/:feedbackId/replies', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId); const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId);
const content = String(req.body?.content ?? '').trim(); const projectId = await workProjectId(workId);
if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; }
if (!projectId || !await canWriteProject(req, projectId)) { res.status(projectId ? 403 : 404).json({ error: projectId ? '无权操作该作品' : '作品不存在' }); return; }
const target = await findFeedbackTarget(workId, type, feedbackId);
const state = await database.one<{ version_number: number; review_status: string; project_status: string; round_status: string }>('SELECT n.version_number,p.review_status,p.status AS project_status,r.status AS round_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
if (!target) { res.status(404).json({ error: '反馈不存在' }); return; }
if (!state || state.project_status !== 'active' || state.review_status === 'completed' || state.round_status !== 'reviewing' || Number(target.version_number) !== Number(state.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
if (!content || content.length > 1000) { res.status(400).json({ error: '回复内容须为 11000 个字符' }); return; }
res.status(201).json(await addFeedbackReply(target, type, feedbackId, content, req.authUser?.display_name || 'API', 'operator'));
});
router.post('/:workId/feedback/:type/:feedbackId/withdraw', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId); const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId); const projectId = await workProjectId(workId);
if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; }
if (!projectId || !await canWriteProject(req, projectId)) { res.status(projectId ? 403 : 404).json({ error: projectId ? '无权操作该作品' : '作品不存在' }); return; }
const target = await findFeedbackTarget(workId, type, feedbackId);
if (!target) { res.status(404).json({ error: '反馈不存在' }); return; }
const state = await database.one<{ version_number: number; review_status: string; project_status: string; round_status: string }>('SELECT n.version_number,p.review_status,p.status AS project_status,r.status AS round_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
if (!state || state.project_status !== 'active' || state.review_status === 'completed' || state.round_status !== 'reviewing' || Number(target.version_number) !== Number(state.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
if (target.withdrawn_at) { res.json({ success: true }); return; }
if (target.author_role !== 'operator' || target.author_name !== (req.authUser?.display_name || 'API')) { res.status(403).json({ error: '只能撤回自己提交的反馈' }); return; }
await withdrawFeedback(type, feedbackId); res.json({ success: true });
});
export default router;

View File

@@ -0,0 +1,46 @@
import type { CollectionStatus } from '../../shared/types.js';
import { database, databaseDialect, type QueryContext } from '../database.js';
export interface CollectionStatusResult {
status: CollectionStatus;
workCount: number;
approvedCount: number;
completedAt: string | null;
}
export function deriveCollectionStatus(workCount: number, approvedCount: number): Exclude<CollectionStatus, 'archived'> {
if (workCount === 0) return 'draft';
if (approvedCount === workCount) return 'completed';
return 'reviewing';
}
export async function recalculateCollectionStatus(
collectionId: number,
tx: QueryContext = database,
): Promise<CollectionStatusResult | null> {
const collection = await tx.one<{ status: CollectionStatus; completed_at: string | null }>(
`SELECT status, completed_at FROM collections WHERE id = ?${databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''}`,
[collectionId],
);
if (!collection) return null;
const counts = await tx.one<{ work_count: number | string; approved_count: number | string }>(
`SELECT COUNT(*) AS work_count,
SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count
FROM notes WHERE collection_id = ? AND review_status != 'draft'`,
[collectionId],
);
const workCount = Number(counts?.work_count ?? 0);
const approvedCount = Number(counts?.approved_count ?? 0);
if (collection.status === 'archived') {
return { status: 'archived', workCount, approvedCount, completedAt: collection.completed_at };
}
const status = deriveCollectionStatus(workCount, approvedCount);
const completedAt = status === 'completed'
? collection.completed_at ?? new Date().toISOString()
: null;
await tx.execute('UPDATE collections SET status = ?, completed_at = ? WHERE id = ?', [status, completedAt, collectionId]);
return { status, workCount, approvedCount, completedAt };
}

View File

@@ -0,0 +1,30 @@
import { database } from '../database.js';
import type { FeedbackReply, FeedbackType } from '../../shared/types.js';
export type FeedbackTarget = {
note_id: number;
version_number: number;
author_name: string;
author_role: 'client' | 'operator';
withdrawn_at: string | null;
};
export async function findFeedbackTarget(workId: number, type: FeedbackType, feedbackId: number): Promise<FeedbackTarget | undefined> {
if (type === 'image_annotation') {
return database.one<FeedbackTarget>(`SELECT i.note_id,i.version_number,a.author_name,a.author_role,a.withdrawn_at
FROM annotations a JOIN images i ON i.id=a.image_id WHERE a.id=? AND i.note_id=?`, [feedbackId, workId]);
}
const table = type === 'text_annotation' ? 'text_annotations' : 'work_comments';
return database.one<FeedbackTarget>(`SELECT note_id,version_number,author_name,author_role,withdrawn_at FROM ${table} WHERE id=? AND note_id=?`, [feedbackId, workId]);
}
export async function addFeedbackReply(target: FeedbackTarget, type: FeedbackType, feedbackId: number, content: string, authorName: string, authorRole: 'client' | 'operator'): Promise<FeedbackReply> {
const id = await database.insertId(`INSERT INTO feedback_replies (note_id,version_number,feedback_type,feedback_id,content,author_name,author_role)
VALUES (?,?,?,?,?,?,?)`, [target.note_id, target.version_number, type, feedbackId, content, authorName, authorRole]);
return (await database.one<FeedbackReply>('SELECT * FROM feedback_replies WHERE id=?', [id]))!;
}
export async function withdrawFeedback(type: FeedbackType, feedbackId: number): Promise<void> {
const table = type === 'image_annotation' ? 'annotations' : type === 'text_annotation' ? 'text_annotations' : 'work_comments';
await database.execute(`UPDATE ${table} SET withdrawn_at=CURRENT_TIMESTAMP WHERE id=? AND withdrawn_at IS NULL`, [feedbackId]);
}

View File

@@ -1,105 +1,252 @@
import sharp from 'sharp';
import type { Note, NoteDetail, ImageWithAnnotations, ReviewStatus } from '../../shared/types.js';
import type { ImageWithAnnotations, Note, NoteDetail, ReviewStatus, WorkFeedbackBundle, WorkRound } from '../../shared/types.js';
import { notesRepository } from '../repositories/notesRepository.js';
import { imagesRepository } from '../repositories/imagesRepository.js';
import { annotationsRepository } from '../repositories/annotationsRepository.js';
import { database, withTransaction } from '../database.js';
import { storeUploadedFile } from '../storage.js';
import { database, databaseDialect, withTransaction, type QueryContext } from '../database.js';
import { storeExternalImageUrl, storeUploadedFile } from '../storage.js';
import { recalculateCollectionStatus } from './collectionsService.js';
import { ensureProjectCompatibilityCollection, recalculateProjectReviewStatus } from './projectsService.js';
export interface UploadedFile { filename: string; originalname?: string; mimetype?: string; path: string }
export interface UploadRound { title: string; description: string; tags: string[]; files: UploadedFile[] }
export interface UrlRound { title: string; description: string; tags: string[]; images: string[] }
type StoredImage = { url: string; width: number; height: number; storageProvider: 'local' | 'tencent_cos' | 'external'; storageKey: string };
type PreparedRound = { title: string; description: string; tags: string[]; images: StoredImage[] };
async function readImageSize(filePath: string): Promise<{ width: number; height: number }> {
try { const meta = await sharp(filePath).metadata(); return { width: meta.width ?? 0, height: meta.height ?? 0 }; }
catch { return { width: 0, height: 0 }; }
}
async function prepareFiles(files: UploadedFile[]) {
async function prepareFiles(files: UploadedFile[]): Promise<StoredImage[]> {
return Promise.all(files.map(async (file) => ({ ...(await readImageSize(file.path)), ...(await storeUploadedFile(file)) })));
}
async function prepareExternalImages(images: string[]): Promise<StoredImage[]> {
const prepared: StoredImage[] = [];
for (const image of images) prepared.push(await storeExternalImageUrl(image));
return prepared;
}
async function createRoundInTransaction(
tx: QueryContext,
noteId: number,
projectId: number,
collectionId: number,
round: PreparedRound,
createdBy: number | undefined,
fromStatus: ReviewStatus,
): Promise<{ roundId: number; roundNumber: number; versionNumber: number }> {
const note = await tx.one<{ active_round_id: number | null }>(
`SELECT active_round_id FROM notes WHERE id = ?${databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''}`,
[noteId],
);
if (note?.active_round_id) {
await tx.execute("UPDATE work_versions SET candidate_status = 'not_selected', review_status = 'draft' WHERE review_round_id = ? AND review_status != 'approved'", [note.active_round_id]);
await tx.execute("UPDATE review_rounds SET status = 'completed', completion_reason = 'superseded', completed_at = COALESCE(completed_at, ?) WHERE id = ? AND status IN ('draft', 'reviewing')", [new Date().toISOString(), note.active_round_id]);
}
const maxima = await tx.one<{ max_version: number | string | null; max_round: number | string | null }>(
`SELECT (SELECT MAX(version_number) FROM work_versions WHERE note_id = ?) AS max_version,
(SELECT MAX(round_number) FROM review_rounds WHERE note_id = ?) AS max_round`,
[noteId, noteId],
);
const versionNumber = Number(maxima?.max_version ?? 0) + 1;
const roundNumber = Number(maxima?.max_round ?? 0) + 1;
const roundId = await tx.insertId(
"INSERT INTO review_rounds (note_id, round_number, status, created_by) VALUES (?, ?, 'reviewing', ?)",
[noteId, roundNumber, createdBy ?? null],
);
await tx.execute(
"INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, review_round_id, candidate_name, candidate_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?, '', 'pending', ?)",
[noteId, versionNumber, round.title, round.description, JSON.stringify(round.tags), roundId, createdBy ?? null],
);
await imagesRepository.createMany(noteId, round.images, versionNumber, tx);
await tx.execute(
"INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')",
[noteId, versionNumber, fromStatus, '工作台'],
);
await tx.execute(
"UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, active_round_id = ?, approved_version_number = NULL, review_status = 'pending' WHERE id = ?",
[round.title, round.description, JSON.stringify(round.tags), versionNumber, roundId, noteId],
);
await recalculateCollectionStatus(collectionId, tx);
await recalculateProjectReviewStatus(projectId, tx);
return { roundId, roundNumber, versionNumber };
}
async function prepareUploadRound(round: UploadRound): Promise<PreparedRound> {
return { title: round.title, description: round.description, tags: round.tags, images: await prepareFiles(round.files) };
}
function mapRound(row: Omit<WorkRound, 'tags'> & { tags: string }): WorkRound {
return {
...row,
version_number: Number(row.version_number),
review_round_id: Number(row.review_round_id),
round_number: Number(row.round_number),
tags: JSON.parse(row.tags || '[]') as string[],
};
}
export const notesService = {
async list(query: { sort?: string; order?: string; q?: string; collectionId?: number; status?: ReviewStatus; tag?: string; projectId?: number; groupId?: number; externalId?: string }) {
return notesRepository.list({ sort: query.sort === 'annotations' ? 'annotations' : 'created_at', order: query.order === 'asc' ? 'asc' : 'desc', q: query.q, collectionId: query.collectionId, status: query.status, tag: query.tag, projectId: query.projectId, groupId: query.groupId, externalId: query.externalId });
},
async getDetail(id: number, requestedVersion?: number): Promise<NoteDetail | null> {
async getDetail(id: number, requestedVersion?: number, requestedRound?: number): Promise<NoteDetail | null> {
const current = await notesRepository.findById(id);
if (!current) return null;
const selectedVersion = requestedVersion && requestedVersion !== current.version_number
? await database.one<{ version_number: number; title: string; description: string; tags: string; review_status: ReviewStatus }>('SELECT version_number, title, description, tags, review_status FROM work_versions WHERE note_id = ? AND version_number = ?', [id, requestedVersion])
const requested = requestedRound
? await database.one<{ version_number: number }>('SELECT v.version_number FROM work_versions v JOIN review_rounds r ON r.id = v.review_round_id WHERE v.note_id = ? AND r.round_number = ?', [id, requestedRound])
: undefined;
if (requestedVersion && requestedVersion !== current.version_number && !selectedVersion) return null;
const targetVersion = requested ? Number(requested.version_number) : requestedVersion;
const selectedVersion = targetVersion && targetVersion !== current.version_number
? await database.one<{ version_number: number; title: string; description: string; tags: string; review_status: ReviewStatus }>('SELECT version_number, title, description, tags, review_status FROM work_versions WHERE note_id = ? AND version_number = ?', [id, targetVersion])
: undefined;
if (targetVersion && targetVersion !== current.version_number && !selectedVersion) return null;
const note: Note = selectedVersion ? { ...current, ...selectedVersion, version_number: Number(selectedVersion.version_number), tags: JSON.parse(selectedVersion.tags || '[]') as string[] } : current;
const images = await imagesRepository.listByNote(id, note.version_number);
const workContext = await database.one<{ project_id: number; project_name: string; slug: string; collection_id: number; collection_name: string }>(`SELECT p.id AS project_id, p.name AS project_name, p.slug, c.id AS collection_id, c.name AS collection_name FROM notes n JOIN collections c ON c.id = n.collection_id JOIN projects p ON p.id = c.project_id WHERE n.id = ?`, [id]);
if (!workContext) return null;
const versionRows = await database.all<Array<Omit<NoteDetail['versions'][number], 'tags'> & { tags: string }>[number]>('SELECT version_number, title, description, tags, review_status, created_at FROM work_versions WHERE note_id = ? ORDER BY version_number DESC', [id]);
const project = await database.one<{ id: number; name: string; slug: string; status: NoteDetail['project']['status']; review_status: NoteDetail['project']['review_status'] }>('SELECT p.id, p.name, p.slug, p.status, p.review_status FROM notes n JOIN projects p ON p.id = n.project_id WHERE n.id = ?', [id]);
if (!project) return null;
const roundRows = await database.all<Array<Omit<WorkRound, 'tags'> & { tags: string }>[number]>(`SELECT v.version_number, v.title, v.description, v.tags, v.review_status, v.review_round_id,
v.created_at, r.round_number, r.status AS round_status, r.completion_reason
FROM work_versions v JOIN review_rounds r ON r.id = v.review_round_id
WHERE v.note_id = ? ORDER BY r.round_number DESC`, [id]);
const result: NoteDetail = {
...note,
images: [] as ImageWithAnnotations[],
text_annotations: await database.all<NoteDetail['text_annotations'][number]>('SELECT id, note_id, version_number, target, content, author_name, status, created_at FROM text_annotations WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
comments: await database.all<NoteDetail['comments'][number]>('SELECT * FROM work_comments WHERE note_id = ? ORDER BY id ASC', [id]),
versions: versionRows.map((item) => ({ ...item, version_number: Number(item.version_number), tags: JSON.parse(item.tags || '[]') as string[] })),
review_events: await database.all<NoteDetail['review_events'][number]>('SELECT * FROM review_events WHERE note_id = ? ORDER BY id DESC', [id]),
project: { id: Number(workContext.project_id), name: workContext.project_name, slug: workContext.slug },
collection: { id: Number(workContext.collection_id), name: workContext.collection_name },
text_annotations: await database.all<NoteDetail['text_annotations'][number]>('SELECT * FROM text_annotations WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
comments: await database.all<NoteDetail['comments'][number]>('SELECT * FROM work_comments WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
feedback_replies: await database.all<NoteDetail['feedback_replies'][number]>('SELECT * FROM feedback_replies WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
rounds: roundRows.map(mapRound),
review_events: await database.all<NoteDetail['review_events'][number]>('SELECT * FROM review_events WHERE note_id = ? AND version_number = ? ORDER BY id DESC', [id, note.version_number]),
project: { id: Number(project.id), name: project.name, slug: project.slug, status: project.status, review_status: project.review_status },
};
for (const image of images) result.images.push({ ...image, annotations: await annotationsRepository.listByImage(image.id) });
return result;
},
async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[], externalId: string | null = null): Promise<Note> {
async createInProject(projectId: number, title: string, description: string, files: UploadedFile[], tags: string[], externalId: string | null = null): Promise<Note> {
const prepared = await prepareFiles(files);
const noteId = await withTransaction(async (tx) => {
const id = await tx.insertId('INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)', [externalId, title, description, collectionId, JSON.stringify(tags), 'pending']);
await imagesRepository.createMany(id, prepared, 1, tx);
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]);
return id;
return withTransaction(async (tx) => {
const collectionId = await ensureProjectCompatibilityCollection(projectId, tx);
const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]);
await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft');
return (await notesRepository.findById(id))!;
});
},
async createInProjectFromUrls(projectId: number, title: string, description: string, imageUrls: string[], tags: string[], externalId: string | null = null): Promise<Note> {
const prepared = await prepareExternalImages(imageUrls);
return withTransaction(async (tx) => {
const collectionId = await ensureProjectCompatibilityCollection(projectId, tx);
const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]);
await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft');
return (await notesRepository.findById(id))!;
});
},
async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[], externalId: string | null = null): Promise<Note> {
const collection = await database.one<{ project_id: number }>('SELECT project_id FROM collections WHERE id = ?', [collectionId]);
if (!collection) throw new Error('作品交付集不存在');
const projectId = Number(collection.project_id);
const prepared = await prepareFiles(files);
return withTransaction(async (tx) => {
const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]);
await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft');
return (await notesRepository.findById(id))!;
});
return (await notesRepository.findById(noteId))!;
},
async createFromUrls(title: string, description: string, imageUrls: string[], collectionId: number, tags: string[], externalId: string | null = null): Promise<Note> {
const noteId = await withTransaction(async (tx) => {
const id = await tx.insertId('INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)', [externalId, title, description, collectionId, JSON.stringify(tags), 'pending']);
await imagesRepository.createMany(id, imageUrls.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external' as const, storageKey: '' })), 1, tx);
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]);
return id;
const collection = await database.one<{ project_id: number }>('SELECT project_id FROM collections WHERE id = ?', [collectionId]);
if (!collection) throw new Error('作品交付集不存在');
const projectId = Number(collection.project_id);
const prepared = await prepareExternalImages(imageUrls);
return withTransaction(async (tx) => {
const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]);
await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft');
return (await notesRepository.findById(id))!;
});
return (await notesRepository.findById(noteId))!;
},
async findByExternalId(collectionId: number, externalId: string): Promise<Note | null> {
return notesRepository.findByExternalId(collectionId, externalId);
async findByExternalId(collectionId: number, externalId: string): Promise<Note | null> { return notesRepository.findByExternalId(collectionId, externalId); },
async findByProjectExternalId(projectId: number, externalId: string): Promise<Note | null> { return notesRepository.findByProjectExternalId(projectId, externalId); },
async createRound(id: number, round: UploadRound, createdBy?: number): Promise<Note> {
const current = await notesRepository.findById(id);
if (!current) throw new Error('作品不存在');
const prepared = await prepareUploadRound(round);
await withTransaction((tx) => createRoundInTransaction(tx, id, current.project_id, current.collection_id, prepared, createdBy, current.review_status));
return (await notesRepository.findById(id))!;
},
async createRoundFromUrls(id: number, round: UrlRound, createdBy?: number): Promise<Note> {
const current = await notesRepository.findById(id);
if (!current) throw new Error('作品不存在');
const prepared = { ...round, images: await prepareExternalImages(round.images) };
await withTransaction((tx) => createRoundInTransaction(tx, id, current.project_id, current.collection_id, prepared, createdBy, current.review_status));
return (await notesRepository.findById(id))!;
},
async createVersion(id: number, title: string, description: string, files: UploadedFile[], tags: string[], createdBy?: number): Promise<Note> {
const current = await notesRepository.findById(id);
if (!current) throw new Error('作品不存在');
const nextVersion = current.version_number + 1;
const prepared = await prepareFiles(files);
await withTransaction(async (tx) => {
await tx.execute("UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, review_status = 'pending' WHERE id = ?", [title, description, JSON.stringify(tags), nextVersion, id]);
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?)", [id, nextVersion, title, description, JSON.stringify(tags), createdBy ?? null]);
await imagesRepository.createMany(id, prepared, nextVersion, tx);
await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')", [id, nextVersion, current.review_status, '工作台']);
});
return (await notesRepository.findById(id))!;
return this.createRound(id, { title, description, tags, files }, createdBy);
},
async createVersionFromUrls(id: number, title: string, description: string, imageUrls: string[], tags: string[], createdBy?: number): Promise<Note> {
const current = await notesRepository.findById(id);
if (!current) throw new Error('作品不存在');
const nextVersion = current.version_number + 1;
await withTransaction(async (tx) => {
await tx.execute("UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, review_status = 'pending' WHERE id = ?", [title, description, JSON.stringify(tags), nextVersion, id]);
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?)", [id, nextVersion, title, description, JSON.stringify(tags), createdBy ?? null]);
await imagesRepository.createMany(id, imageUrls.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external' as const, storageKey: '' })), nextVersion, tx);
await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')", [id, nextVersion, current.review_status, '工作台']);
});
return (await notesRepository.findById(id))!;
return this.createRoundFromUrls(id, { title, description, tags, images: imageUrls }, createdBy);
},
async remove(id: number) { return notesRepository.remove(id); },
async setStatus(id: number, status: ReviewStatus) { return notesRepository.setStatus(id, status); },
async getFeedback(id: number): Promise<WorkFeedbackBundle | null> {
const rounds = await database.all<{ round_number: number; version_number: number }>('SELECT r.round_number, v.version_number FROM review_rounds r JOIN work_versions v ON v.review_round_id = r.id WHERE r.note_id = ? ORDER BY r.round_number DESC', [id]);
if (!rounds.length && !await notesRepository.findById(id)) return null;
const imageAnnotations = await database.all<Array<WorkFeedbackBundle['rounds'][number]['image_annotations'][number] & { version_number: number }>[number]>(`SELECT a.*, i.id AS image_id, i.url AS image_url, i.version_number FROM annotations a JOIN images i ON i.id = a.image_id WHERE i.note_id = ? ORDER BY a.id`, [id]);
const textAnnotations = await database.all<Array<NoteDetail['text_annotations'][number] & { version_number: number }>[number]>('SELECT * FROM text_annotations WHERE note_id = ? ORDER BY id', [id]);
const comments = await database.all<NoteDetail['comments'][number]>('SELECT * FROM work_comments WHERE note_id = ? ORDER BY id', [id]);
const replies = await database.all<NoteDetail['feedback_replies'][number]>('SELECT * FROM feedback_replies WHERE note_id = ? ORDER BY id', [id]);
const events = await database.all<NoteDetail['review_events'][number]>('SELECT * FROM review_events WHERE note_id = ? ORDER BY id', [id]);
return {
work_id: id,
rounds: rounds.map((round) => ({
round_number: Number(round.round_number),
version_number: Number(round.version_number),
image_annotations: imageAnnotations.filter((item) => Number(item.version_number) === Number(round.version_number)),
text_annotations: textAnnotations.filter((item) => Number(item.version_number) === Number(round.version_number)),
comments: comments.filter((item) => Number(item.version_number) === Number(round.version_number)),
feedback_replies: replies.filter((item) => Number(item.version_number) === Number(round.version_number)),
review_events: events.filter((item) => Number(item.version_number) === Number(round.version_number)),
})),
};
},
async remove(id: number) {
return withTransaction(async (tx) => {
const note = await tx.one<{ collection_id: number; project_id: number }>('SELECT collection_id, project_id FROM notes WHERE id = ?', [id]);
if (!note) return false;
const removed = (await tx.execute('DELETE FROM notes WHERE id = ?', [id])).changes > 0;
if (removed) {
await recalculateCollectionStatus(Number(note.collection_id), tx);
await recalculateProjectReviewStatus(Number(note.project_id), tx);
}
return removed;
});
},
async setStatus(id: number, status: ReviewStatus) {
return withTransaction(async (tx) => {
const note = await tx.one<{ collection_id: number; project_id: number; active_round_id: number | null }>('SELECT collection_id, project_id, active_round_id FROM notes WHERE id = ?', [id]);
if (!note) return false;
await tx.execute('UPDATE notes SET review_status = ? WHERE id = ?', [status, id]);
if (note.active_round_id) {
await tx.execute("UPDATE work_versions SET review_status = ?, candidate_status = ? WHERE review_round_id = ?", [status, status === 'draft' ? 'draft' : 'pending', note.active_round_id]);
await tx.execute("UPDATE review_rounds SET status = ?, completion_reason = '', selected_version_number = NULL, completed_at = NULL WHERE id = ?", [status === 'draft' ? 'draft' : 'reviewing', note.active_round_id]);
}
await recalculateCollectionStatus(Number(note.collection_id), tx);
await recalculateProjectReviewStatus(Number(note.project_id), tx);
return true;
});
},
};

View File

@@ -0,0 +1,55 @@
import type { ProjectReviewStatus } from '../../shared/types.js';
import { database, databaseDialect, type QueryContext } from '../database.js';
export interface ProjectReviewStatusResult {
reviewStatus: ProjectReviewStatus;
workCount: number;
approvedCount: number;
completedAt: string | null;
}
export function deriveProjectReviewStatus(workCount: number, approvedCount: number): Exclude<ProjectReviewStatus, 'archived'> {
if (workCount === 0) return 'draft';
if (approvedCount === workCount) return 'completed';
return 'reviewing';
}
export async function recalculateProjectReviewStatus(
projectId: number,
tx: QueryContext = database,
): Promise<ProjectReviewStatusResult | null> {
const project = await tx.one<{ status: string; review_status: ProjectReviewStatus; review_completed_at: string | null }>(
`SELECT status, review_status, review_completed_at FROM projects WHERE id = ?${databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''}`,
[projectId],
);
if (!project) return null;
const counts = await tx.one<{ work_count: number | string; approved_count: number | string }>(
`SELECT COUNT(*) AS work_count,
SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count
FROM notes WHERE project_id = ? AND review_status != 'draft'`,
[projectId],
);
const workCount = Number(counts?.work_count ?? 0);
const approvedCount = Number(counts?.approved_count ?? 0);
if (project.status === 'archived') {
return { reviewStatus: 'archived', workCount, approvedCount, completedAt: project.review_completed_at };
}
const reviewStatus = deriveProjectReviewStatus(workCount, approvedCount);
const completedAt = reviewStatus === 'completed'
? project.review_completed_at ?? new Date().toISOString()
: null;
await tx.execute('UPDATE projects SET review_status = ?, review_completed_at = ? WHERE id = ?', [reviewStatus, completedAt, projectId]);
return { reviewStatus, workCount, approvedCount, completedAt };
}
export async function ensureProjectCompatibilityCollection(projectId: number, tx: QueryContext = database): Promise<number> {
const existing = await tx.one<{ id: number }>('SELECT id FROM collections WHERE project_id = ? ORDER BY id LIMIT 1', [projectId]);
if (existing) return Number(existing.id);
return tx.insertId(
"INSERT INTO collections (project_id, name, client_description, status) VALUES (?, ?, '', 'draft')",
[projectId, '__project_default__'],
);
}

View File

@@ -0,0 +1,64 @@
import type { ReviewStatus } from '../../shared/types.js';
import { databaseDialect, withTransaction, type QueryContext } from '../database.js';
import { recalculateCollectionStatus } from './collectionsService.js';
import { recalculateProjectReviewStatus } from './projectsService.js';
export class ReviewDecisionError extends Error {
constructor(public statusCode: number, message: string) { super(message); }
}
export interface RoundDecisionInput {
noteId: number;
versionNumber: number;
projectId: number;
decision: 'approved' | 'changes_requested';
reason: string;
actorName: string;
actorRole: 'client';
}
export async function decideRoundInTransaction(tx: QueryContext, input: RoundDecisionInput) {
const round = await tx.one<{
review_round_id: number; review_status: ReviewStatus; collection_id: number; project_id: number;
active_round_id: number | null; round_status: string; project_status: string; title: string; description: string; tags: string;
}>(`SELECT v.review_round_id,v.review_status,v.title,v.description,v.tags,
n.collection_id,n.project_id,n.active_round_id,r.status AS round_status,p.status AS project_status
FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN review_rounds r ON r.id=v.review_round_id JOIN projects p ON p.id=n.project_id
WHERE v.note_id=? AND v.version_number=? AND n.project_id=?${databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''}`,
[input.noteId, input.versionNumber, input.projectId]);
if (!round) throw new ReviewDecisionError(404, '验收轮次不存在');
if (round.project_status !== 'active') throw new ReviewDecisionError(409, '已关闭或归档项目为只读状态');
if (Number(round.active_round_id) !== Number(round.review_round_id) || round.round_status !== 'reviewing') {
throw new ReviewDecisionError(409, '历史验收轮次为只读状态');
}
if (!['pending', 'changes_requested'].includes(round.review_status)) {
throw new ReviewDecisionError(409, '该轮次当前不能重复验收');
}
const now = new Date().toISOString();
if (input.decision === 'approved') {
await tx.execute("UPDATE work_versions SET candidate_status='selected', review_status='approved' WHERE note_id=? AND version_number=?", [input.noteId, input.versionNumber]);
await tx.execute("UPDATE review_rounds SET status='completed', completion_reason='approved', selected_version_number=?, completed_at=? WHERE id=?", [input.versionNumber, now, round.review_round_id]);
await tx.execute("UPDATE notes SET title=?,description=?,tags=?,version_number=?,approved_version_number=?,review_status='approved' WHERE id=?", [round.title, round.description, round.tags, input.versionNumber, input.versionNumber, input.noteId]);
await tx.execute("UPDATE annotations SET status='confirmed', closure_reason='approved_with_round' WHERE image_id IN (SELECT id FROM images WHERE note_id=? AND version_number=?) AND status='open' AND withdrawn_at IS NULL", [input.noteId, input.versionNumber]);
await tx.execute("UPDATE text_annotations SET status='confirmed', closure_reason='approved_with_round' WHERE note_id=? AND version_number=? AND status='open' AND withdrawn_at IS NULL", [input.noteId, input.versionNumber]);
await tx.execute("UPDATE work_comments SET status='confirmed', closure_reason='approved_with_round' WHERE note_id=? AND version_number=? AND status='open' AND withdrawn_at IS NULL", [input.noteId, input.versionNumber]);
} else {
await tx.execute("UPDATE work_versions SET candidate_status='changes_requested', review_status='changes_requested' WHERE note_id=? AND version_number=?", [input.noteId, input.versionNumber]);
await tx.execute("UPDATE review_rounds SET status='completed', completion_reason='changes_requested', completed_at=? WHERE id=?", [now, round.review_round_id]);
await tx.execute("UPDATE notes SET title=?,description=?,tags=?,version_number=?,review_status='changes_requested' WHERE id=?", [round.title, round.description, round.tags, input.versionNumber, input.noteId]);
}
await tx.execute('INSERT INTO review_events (note_id,version_number,event_type,from_status,to_status,reason,actor_name,actor_role) VALUES (?,?,?,?,?,?,?,?)', [input.noteId, input.versionNumber, input.decision, round.review_status, input.decision, input.reason, input.actorName, input.actorRole]);
if (input.reason) await tx.execute("INSERT INTO work_comments (note_id,version_number,content,author_name,author_role) VALUES (?,?,?,?,'client')", [input.noteId, input.versionNumber, input.reason, input.actorName]);
await recalculateCollectionStatus(Number(round.collection_id), tx);
await recalculateProjectReviewStatus(Number(round.project_id), tx);
return { success: true as const, status: input.decision, version_number: input.versionNumber };
}
export async function decideRound(input: RoundDecisionInput) {
return withTransaction((tx) => decideRoundInTransaction(tx, input));
}
export const decideCandidate = decideRound;
export const decideCandidateInTransaction = decideRoundInTransaction;

View File

@@ -1,7 +1,10 @@
import COS from 'cos-nodejs-sdk-v5';
import fs from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';
import { createHash, randomUUID } from 'crypto';
import { lookup } from 'dns/promises';
import { isIP } from 'net';
import sharp from 'sharp';
import { database } from './database.js';
import { decryptSecret } from './configCrypto.js';
@@ -22,6 +25,28 @@ export interface StoredUpload {
storageKey: string;
}
export interface StoredExternalImage extends StoredUpload {
width: number;
height: number;
}
export class StorageImportError extends Error {
constructor(public statusCode: number, message: string) { super(message); }
}
const MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024;
const MAX_REMOTE_REDIRECTS = 3;
const IMAGE_CONTENT_TYPES = new Map([
['image/jpeg', '.jpg'],
['image/jpg', '.jpg'],
['image/png', '.png'],
['image/gif', '.gif'],
['image/webp', '.webp'],
['image/avif', '.avif'],
['image/heic', '.heic'],
['image/heif', '.heif'],
]);
export async function getActiveStorageConfig(): Promise<StorageConfigRecord | undefined> {
return database.one<StorageConfigRecord>(`
SELECT id, region, bucket, public_base_url, cdn_domain, path_prefix,
@@ -46,6 +71,99 @@ function objectUrl(config: StorageConfigRecord, key: string): string {
return `${base}/${key.split('/').map(encodeURIComponent).join('/')}`;
}
function configuredOrigins(config: StorageConfigRecord): Set<string> {
const values = [config.cdn_domain, config.public_base_url, `https://${config.bucket}.cos.${config.region}.myqcloud.com`];
return new Set(values.filter(Boolean).map((value) => new URL(value).origin));
}
function isPrivateIpv4(address: string): boolean {
const parts = address.split('.').map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
const [a, b, c] = parts;
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127)
|| (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
|| (a === 192 && b === 0 && c === 0) || (a === 192 && b === 0 && c === 2)
|| (a === 198 && (b === 18 || b === 19)) || (a === 198 && b === 51 && c === 100)
|| (a === 203 && b === 0 && c === 113) || a >= 224;
}
function isPrivateAddress(address: string): boolean {
const normalized = address.toLowerCase().split('%')[0];
if (isIP(normalized) === 4) return isPrivateIpv4(normalized);
if (isIP(normalized) !== 6) return true;
if (normalized.startsWith('::ffff:')) return isPrivateIpv4(normalized.slice(7));
return normalized === '::' || normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd')
|| normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea')
|| normalized.startsWith('feb') || normalized.startsWith('ff');
}
async function assertPublicRemote(url: URL): Promise<void> {
assertRemoteUrlShape(url);
const rawHost = url.hostname.toLowerCase();
const host = rawHost.startsWith('[') && rawHost.endsWith(']') ? rawHost.slice(1, -1) : rawHost;
if (host === 'localhost' || host.endsWith('.localhost')) throw new StorageImportError(400, '外部图片地址不能指向本机或内网');
let addresses: Array<{ address: string }>;
try { addresses = isIP(host) ? [{ address: host }] : await lookup(host, { all: true, verbatim: true }); }
catch { throw new StorageImportError(422, '无法解析外部图片地址'); }
if (!addresses.length || addresses.some((item) => isPrivateAddress(item.address))) {
throw new StorageImportError(400, '外部图片地址不能指向本机、内网或保留地址');
}
}
export async function assertPublicHttpUrl(value: string): Promise<void> {
await assertPublicRemote(new URL(value));
}
function assertRemoteUrlShape(url: URL): void {
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new StorageImportError(400, '图片地址必须是公开的 HTTP/HTTPS URL');
}
if ((url.protocol === 'http:' && url.port && url.port !== '80') || (url.protocol === 'https:' && url.port && url.port !== '443')) {
throw new StorageImportError(400, '外部图片地址只能使用标准 HTTP/HTTPS 端口');
}
}
async function downloadRemoteImage(source: URL, redirectCount = 0): Promise<{ body: Buffer; contentType: string }> {
await assertPublicRemote(source);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
try {
let response: Response;
try {
response = await fetch(source, { redirect: 'manual', signal: controller.signal, headers: { Accept: 'image/*', 'User-Agent': 'Delivery-Desk/1.0' } });
} catch (error) {
throw new StorageImportError(422, `外部图片下载失败:${error instanceof Error ? error.message : '网络错误'}`);
}
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location');
await response.body?.cancel();
if (!location || redirectCount >= MAX_REMOTE_REDIRECTS) throw new StorageImportError(422, '外部图片重定向无效或次数过多');
return downloadRemoteImage(new URL(location, source), redirectCount + 1);
}
if (!response.ok || !response.body) { await response.body?.cancel(); throw new StorageImportError(422, `外部图片下载失败HTTP ${response.status}`); }
const contentType = response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() || '';
if (!IMAGE_CONTENT_TYPES.has(contentType)) { await response.body.cancel(); throw new StorageImportError(422, '外部地址返回的不是支持的图片类型'); }
const declaredLength = Number(response.headers.get('content-length') || 0);
if (declaredLength > MAX_REMOTE_IMAGE_BYTES) { await response.body.cancel(); throw new StorageImportError(413, '外部图片不能超过 20 MB'); }
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_REMOTE_IMAGE_BYTES) { await reader.cancel(); throw new StorageImportError(413, '外部图片不能超过 20 MB'); }
chunks.push(value);
}
return { body: Buffer.concat(chunks), contentType };
} catch (error) {
if (error instanceof StorageImportError) throw error;
throw new StorageImportError(422, `外部图片下载失败:${error instanceof Error ? error.message : '网络错误'}`);
} finally {
clearTimeout(timeout);
}
}
function safeError(error: unknown): string {
if (!error || typeof error !== 'object') return 'COS 连接失败';
const item = error as { code?: string; statusCode?: number; message?: string };
@@ -88,3 +206,29 @@ export async function storeUploadedFile(file: { filename: string; originalname?:
fs.unlinkSync(file.path);
return { url: objectUrl(config, key), storageProvider: 'tencent_cos', storageKey: key };
}
export async function storeExternalImageUrl(value: string): Promise<StoredExternalImage> {
const config = await getActiveStorageConfig();
if (!config) throw new StorageImportError(409, '未启用腾讯云 COS 配置,无法导入外部图片 URL');
const source = new URL(value);
await assertPublicRemote(source);
if (configuredOrigins(config).has(source.origin)) {
return { url: source.toString(), width: 0, height: 0, storageProvider: 'tencent_cos', storageKey: source.pathname.replace(/^\/+/, '') };
}
const { body, contentType } = await downloadRemoteImage(source);
let metadata: sharp.Metadata;
try { metadata = await sharp(body).metadata(); }
catch { throw new StorageImportError(422, '外部地址返回的内容不是有效图片'); }
const extension = IMAGE_CONTENT_TYPES.get(contentType)!;
const prefix = cleanPrefix(config.path_prefix);
const digest = createHash('sha256').update(body).digest('hex');
const key = `${prefix ? `${prefix}/` : ''}imports/${digest}${extension}`;
const cos = createClient(config);
try {
await cos.putObject({ Bucket: config.bucket, Region: config.region, Key: key, Body: body, ContentLength: body.length, ContentType: contentType });
} catch (error) {
throw new StorageImportError(502, `外部图片转存 COS 失败:${safeError(error)}`);
}
return { url: objectUrl(config, key), width: metadata.width ?? 0, height: metadata.height ?? 0, storageProvider: 'tencent_cos', storageKey: key };
}

View File

@@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS projects (
slug TEXT NOT NULL UNIQUE,
client_description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed', 'archived')),
review_status TEXT NOT NULL DEFAULT 'draft' CHECK (review_status IN ('draft', 'reviewing', 'completed', 'archived')),
review_completed_at TIMESTAMPTZ,
access_password_hash TEXT NOT NULL DEFAULT '',
customer_access_enabled BOOLEAN NOT NULL DEFAULT FALSE,
access_expires_at TIMESTAMPTZ,
@@ -40,24 +42,33 @@ CREATE TABLE IF NOT EXISTS collections (
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
client_description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'reviewing' CHECK (status IN ('draft', 'reviewing', 'completed', 'archived')),
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'reviewing', 'completed', 'archived')),
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, name)
);
ALTER TABLE collections ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS notes (
id BIGSERIAL PRIMARY KEY,
collection_id BIGINT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE,
external_id TEXT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
review_status TEXT NOT NULL DEFAULT 'pending' CHECK (review_status IN ('draft', 'pending', 'changes_requested', 'approved')),
version_number INTEGER NOT NULL DEFAULT 1 CHECK (version_number > 0),
active_round_id BIGINT,
approved_version_number INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE notes ADD COLUMN IF NOT EXISTS external_id TEXT;
ALTER TABLE notes ADD COLUMN IF NOT EXISTS project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS review_status TEXT NOT NULL DEFAULT 'draft';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS review_completed_at TIMESTAMPTZ;
CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_collection_external_id ON notes(collection_id, external_id) WHERE external_id IS NOT NULL AND external_id != '';
CREATE TABLE IF NOT EXISTS images (
@@ -79,17 +90,23 @@ CREATE TABLE IF NOT EXISTS annotations (
y DOUBLE PRECISION NOT NULL CHECK (y BETWEEN 0 AND 1),
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client' CHECK (author_role IN ('client', 'operator')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')),
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS work_comments (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL DEFAULT 1,
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client' CHECK (author_role IN ('client', 'operator')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')),
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -97,15 +114,38 @@ CREATE TABLE IF NOT EXISTS text_annotations (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL CHECK (version_number > 0),
target TEXT NOT NULL CHECK (target IN ('title', 'description')),
target TEXT NOT NULL CHECK (target IN ('title', 'description', 'tags')),
start_offset INTEGER NOT NULL DEFAULT 0,
end_offset INTEGER NOT NULL DEFAULT 0,
selected_text TEXT NOT NULL DEFAULT '',
prefix_text TEXT NOT NULL DEFAULT '',
suffix_text TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client' CHECK (author_role IN ('client', 'operator')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')),
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_text_annotations_note_id ON text_annotations(note_id, version_number);
CREATE TABLE IF NOT EXISTS feedback_replies (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL CHECK (version_number > 0),
feedback_type TEXT NOT NULL CHECK (feedback_type IN ('image_annotation', 'text_annotation', 'comment')),
feedback_id BIGINT NOT NULL,
content TEXT NOT NULL,
author_name TEXT NOT NULL,
author_role TEXT NOT NULL CHECK (author_role IN ('client', 'operator')),
withdrawn_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_feedback_replies_target ON feedback_replies(note_id, version_number, feedback_type, feedback_id);
CREATE TABLE IF NOT EXISTS work_versions (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
@@ -114,11 +154,54 @@ CREATE TABLE IF NOT EXISTS work_versions (
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
review_status TEXT NOT NULL DEFAULT 'pending' CHECK (review_status IN ('draft', 'pending', 'changes_requested', 'approved')),
review_round_id BIGINT,
candidate_name TEXT NOT NULL DEFAULT '方案 A',
candidate_status TEXT NOT NULL DEFAULT 'pending' CHECK (candidate_status IN ('draft', 'pending', 'changes_requested', 'selected', 'not_selected')),
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (note_id, version_number)
);
CREATE TABLE IF NOT EXISTS review_rounds (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
round_number INTEGER NOT NULL CHECK (round_number > 0),
status TEXT NOT NULL DEFAULT 'reviewing' CHECK (status IN ('draft', 'reviewing', 'completed')),
selected_version_number INTEGER,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
completed_at TIMESTAMPTZ,
completion_reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(note_id, round_number)
);
ALTER TABLE notes ADD COLUMN IF NOT EXISTS active_round_id BIGINT;
ALTER TABLE notes ADD COLUMN IF NOT EXISTS approved_version_number INTEGER;
ALTER TABLE work_versions ADD COLUMN IF NOT EXISTS review_round_id BIGINT;
ALTER TABLE work_versions ADD COLUMN IF NOT EXISTS candidate_name TEXT NOT NULL DEFAULT '方案 A';
ALTER TABLE work_versions ADD COLUMN IF NOT EXISTS candidate_status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE review_rounds ADD COLUMN IF NOT EXISTS completion_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE annotations ADD COLUMN IF NOT EXISTS author_role TEXT NOT NULL DEFAULT 'client';
ALTER TABLE annotations ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ;
ALTER TABLE annotations ADD COLUMN IF NOT EXISTS closure_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE work_comments ADD COLUMN IF NOT EXISTS version_number INTEGER NOT NULL DEFAULT 1;
ALTER TABLE work_comments ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ;
ALTER TABLE work_comments ADD COLUMN IF NOT EXISTS closure_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS start_offset INTEGER NOT NULL DEFAULT 0;
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS end_offset INTEGER NOT NULL DEFAULT 0;
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS selected_text TEXT NOT NULL DEFAULT '';
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS prefix_text TEXT NOT NULL DEFAULT '';
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS suffix_text TEXT NOT NULL DEFAULT '';
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS author_role TEXT NOT NULL DEFAULT 'client';
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ;
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS closure_reason TEXT NOT NULL DEFAULT '';
-- TEXT_ANNOTATION_TARGET_REPAIR_START
ALTER TABLE text_annotations DROP CONSTRAINT IF EXISTS text_annotations_target_check;
ALTER TABLE text_annotations ADD CONSTRAINT text_annotations_target_check
CHECK (target IN ('title', 'description', 'tags'));
-- TEXT_ANNOTATION_TARGET_REPAIR_END
CREATE TABLE IF NOT EXISTS review_events (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
@@ -197,11 +280,151 @@ CREATE TABLE IF NOT EXISTS storage_configs (
CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config ON storage_configs ((status)) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS collections_project_id_idx ON collections(project_id);
CREATE INDEX IF NOT EXISTS notes_collection_id_idx ON notes(collection_id);
CREATE INDEX IF NOT EXISTS notes_project_id_idx ON notes(project_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_project_external_id ON notes(project_id, external_id) WHERE external_id IS NOT NULL AND external_id != '';
CREATE INDEX IF NOT EXISTS images_note_version_idx ON images(note_id, version_number, order_index);
CREATE INDEX IF NOT EXISTS annotations_image_id_idx ON annotations(image_id);
CREATE INDEX IF NOT EXISTS comments_note_id_idx ON work_comments(note_id);
CREATE INDEX IF NOT EXISTS customer_sessions_project_id_idx ON customer_sessions(project_id);
CREATE INDEX IF NOT EXISTS review_events_note_version_idx ON review_events(note_id, version_number);
CREATE INDEX IF NOT EXISTS review_rounds_note_id_idx ON review_rounds(note_id, round_number);
CREATE INDEX IF NOT EXISTS audit_logs_group_id_idx ON audit_logs(group_id);
-- SINGLE_SCHEME_REPAIR_START
DO $$
DECLARE
duplicate RECORD;
version_row RECORD;
source_round RECORD;
note_row RECORD;
keeper_version INTEGER;
next_round INTEGER;
new_round_id BIGINT;
remains_active BOOLEAN;
BEGIN
FOR duplicate IN
SELECT review_round_id, note_id FROM work_versions
WHERE review_round_id IS NOT NULL
GROUP BY review_round_id, note_id HAVING COUNT(*) > 1
LOOP
SELECT * INTO source_round FROM review_rounds WHERE id = duplicate.review_round_id;
SELECT active_round_id, version_number INTO note_row FROM notes WHERE id = duplicate.note_id;
SELECT COALESCE(
(SELECT v.version_number FROM work_versions v WHERE v.review_round_id=duplicate.review_round_id AND v.version_number=note_row.version_number LIMIT 1),
(SELECT MIN(v.version_number) FROM work_versions v WHERE v.review_round_id=duplicate.review_round_id)
) INTO keeper_version;
FOR version_row IN SELECT version_number FROM work_versions WHERE review_round_id=duplicate.review_round_id AND version_number<>keeper_version ORDER BY version_number
LOOP
SELECT COALESCE(MAX(round_number),0)+1 INTO next_round FROM review_rounds WHERE note_id=duplicate.note_id;
remains_active := note_row.active_round_id=duplicate.review_round_id AND note_row.version_number=version_row.version_number;
INSERT INTO review_rounds (note_id,round_number,status,selected_version_number,completed_at,created_by,created_at,completion_reason)
VALUES (
duplicate.note_id,
next_round,
CASE WHEN remains_active THEN source_round.status ELSE 'completed' END,
CASE WHEN source_round.selected_version_number=version_row.version_number THEN version_row.version_number ELSE NULL END,
CASE WHEN remains_active THEN source_round.completed_at ELSE COALESCE(source_round.completed_at,NOW()) END,
source_round.created_by,
source_round.created_at,
CASE WHEN remains_active THEN source_round.completion_reason ELSE COALESCE(NULLIF(source_round.completion_reason,''),'migrated_single_scheme') END
) RETURNING id INTO new_round_id;
UPDATE work_versions SET review_round_id=new_round_id WHERE note_id=duplicate.note_id AND version_number=version_row.version_number;
IF remains_active THEN UPDATE notes SET active_round_id=new_round_id WHERE id=duplicate.note_id; END IF;
END LOOP;
IF source_round.selected_version_number IS DISTINCT FROM keeper_version THEN
UPDATE review_rounds SET selected_version_number=NULL WHERE id=duplicate.review_round_id;
END IF;
END LOOP;
END $$;
-- SINGLE_SCHEME_REPAIR_END
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_versions_one_per_round ON work_versions(review_round_id) WHERE review_round_id IS NOT NULL;
-- COLLECTION_STATUS_REPAIR_START
UPDATE collections
SET status = 'draft', completed_at = NULL
WHERE status != 'archived'
AND id NOT IN (SELECT collection_id FROM notes WHERE review_status != 'draft');
UPDATE collections AS c
SET status = CASE WHEN s.approved_count = s.work_count THEN 'completed' ELSE 'reviewing' END,
completed_at = CASE WHEN s.approved_count = s.work_count THEN COALESCE(c.completed_at, NOW()) ELSE NULL END
FROM (
SELECT collection_id,
COUNT(*) AS work_count,
SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count
FROM notes
WHERE review_status != 'draft'
GROUP BY collection_id
) AS s
WHERE c.id = s.collection_id AND c.status != 'archived';
-- COLLECTION_STATUS_REPAIR_END
-- REVIEW_ROUND_REPAIR_START
INSERT INTO review_rounds (note_id, round_number, status, selected_version_number, completed_at, created_at)
SELECT v.note_id, v.version_number,
CASE WHEN v.version_number = n.version_number AND n.review_status != 'approved' THEN 'reviewing' ELSE 'completed' END,
CASE WHEN v.review_status = 'approved' THEN v.version_number ELSE NULL END,
CASE WHEN v.version_number = n.version_number AND n.review_status != 'approved' THEN NULL ELSE v.created_at END,
v.created_at
FROM work_versions v JOIN notes n ON n.id = v.note_id
ON CONFLICT (note_id, round_number) DO NOTHING;
UPDATE work_versions v
SET review_round_id = r.id,
candidate_name = COALESCE(NULLIF(v.candidate_name, ''), '方案 A'),
candidate_status = CASE
WHEN v.review_status = 'approved' THEN 'selected'
WHEN v.version_number = n.version_number AND v.review_status = 'changes_requested' THEN 'changes_requested'
WHEN v.version_number = n.version_number AND v.review_status = 'pending' THEN 'pending'
WHEN v.version_number = n.version_number AND v.review_status = 'draft' THEN 'draft'
ELSE 'not_selected'
END
FROM review_rounds r, notes n
WHERE r.note_id = v.note_id AND r.round_number = v.version_number AND n.id = v.note_id AND v.review_round_id IS NULL;
UPDATE notes n
SET active_round_id = r.id,
approved_version_number = (SELECT MAX(v.version_number) FROM work_versions v WHERE v.note_id = n.id AND v.review_status = 'approved')
FROM review_rounds r
WHERE r.note_id = n.id AND r.round_number = n.version_number AND n.active_round_id IS NULL;
-- REVIEW_ROUND_REPAIR_END
-- PROJECT_REVIEW_STATUS_REPAIR_START
UPDATE notes n
SET project_id = c.project_id
FROM collections c
WHERE c.id = n.collection_id AND n.project_id IS NULL;
UPDATE work_comments wc
SET version_number = n.version_number
FROM notes n
WHERE n.id = wc.note_id AND wc.version_number < 1;
UPDATE projects p
SET review_status = CASE
WHEN p.status = 'archived' THEN 'archived'
WHEN s.work_count IS NULL OR s.work_count = 0 THEN 'draft'
WHEN s.approved_count = s.work_count THEN 'completed'
ELSE 'reviewing'
END,
review_completed_at = CASE
WHEN p.status != 'archived' AND s.work_count > 0 AND s.approved_count = s.work_count
THEN COALESCE(p.review_completed_at, NOW())
ELSE NULL
END
FROM (
SELECT project_id,
COUNT(*) FILTER (WHERE review_status != 'draft') AS work_count,
COUNT(*) FILTER (WHERE review_status = 'approved') AS approved_count
FROM notes GROUP BY project_id
) s
WHERE p.id = s.project_id;
UPDATE projects p
SET review_status = CASE WHEN p.status = 'archived' THEN 'archived' ELSE 'draft' END,
review_completed_at = NULL
WHERE NOT EXISTS (SELECT 1 FROM notes n WHERE n.project_id = p.id AND n.review_status != 'draft');
-- PROJECT_REVIEW_STATUS_REPAIR_END
COMMIT;

View File

@@ -2,80 +2,80 @@
## 系统边界
Delivery Desk 是单体 Web 应用:React 前端调用 Express API,前后端共享 `shared/types.ts` 类型。开发环境使用 SQLite 和本地上传目录正式环境使用 PostgreSQL 和腾讯云 COS。
Delivery Desk 是 React + Express 单体应用,前后端共享 `shared/types.ts`。开发环境使用 SQLite 和本地上传目录正式环境使用 PostgreSQL 和腾讯云 COS。
```mermaid
flowchart LR
browser["浏览器"] --> app["Express / React 应用"]
app --> database["SQLite 或 PostgreSQL"]
app --> local["本地 uploads开发"]
app --> cos["腾讯云 COS(正式)"]
customer["客户验收链接"] --> app
client["外部 API 客户端"] --> app
browser["工作台 / 客户浏览器"] --> app["Express + React"]
api["外部 API 客户端"] --> app
app --> db["SQLite / PostgreSQL"]
app --> storage["本地 uploads / 腾讯云 COS"]
```
## 业务层级
## 产品层级
```text
运营组
└── 项目
└── 作品交付集
└── 作品
└── 版本
└── 验收轮次(每轮一个方案)
```
- 一个运营组只能有一位组管理员,可以有多位光影叙事
- 平台管理员可以有多位,不属于固定运营组
- 普通工作台账号只能读写所属运营组的数据;平台管理员可跨组管理
- 客户会话只绑定一个项目,不能跨项目浏览
- 平台级 API Key 可创建项目;项目级 API Key 只能操作指定项目
## 运行结构
- `src/`React 页面、组件、状态和 API 客户端。
- `api/routes/`HTTP 路由与输入校验。
- `api/services/`:作品、存储等业务编排。
- `api/repositories/`:查询封装。
- `api/database.ts`SQLite/PostgreSQL 统一查询接口和事务。
- `api/db.ts`SQLite 初始化及增量迁移。
- `db/postgres/schema.sql`PostgreSQL 当前完整 schema。
- `shared/types.ts`:前后端共享领域类型。
`DATABASE_URL` 存在时使用 PostgreSQL否则使用 SQLite。两套数据库必须保持相同业务约束涉及表或字段的修改必须同时更新 `api/db.ts``db/postgres/schema.sql` 及迁移验证脚本。
- 客户会话绑定项目,不能跨项目访问
- 项目级 API Key 只能访问绑定项目;平台级 Key 可跨组管理项目
- 历史作品交付集不再是产品层级。`collections` 表仅作为旧数据和旧 URL 的迁移兼容容器
- `work_versions` 继续保存每轮内容快照,但与 `review_rounds` 强制一对一
- 升级时如检测到旧的一轮多方案数据,会把额外方案拆成只读的独立历史轮次,保留图片、批注、验收事件和当前活动方案,再建立一轮一方案唯一约束
## 主要数据表
| 表 | 用途 |
|---|---|
| `operation_groups` | 运营组及启停状态 |
| `users` / `sessions` | 工作台账号、角色和登录会话 |
| `customer_sessions` | 客户项目级验收会话 |
| `projects` | 项目、客户访问密码和访问期限 |
| `collections` | 项目下的作品交付集 |
| `notes` | 作品当前状态和当前版本 |
| `work_versions` | 各版本标题、正文、标签和状态快照 |
| `images` | 版本图片、顺序、存储提供方和对象 Key |
| `operation_groups` | 运营组及状态 |
| `users` / `sessions` | 工作台账号、角色和会话 |
| `customer_sessions` | 项目级客户会话 |
| `projects` | 项目、客户访问配置和自动验收状态 |
| `collections` | 迁移期内部兼容容器,不属于产品层级 |
| `notes` | 作品当前状态、活动轮次和项目归属 |
| `review_rounds` | 验收轮次与完成原因 |
| `work_versions` | 单轮内容快照;每轮恰好一条 |
| `images` | 轮次图片、顺序和存储信息 |
| `annotations` | 图片坐标批注 |
| `text_annotations` | 标题正文的版本级批注 |
| `work_comments` | 作品总体反馈与回复 |
| `review_events` | 提交、修、通过重新打开等验收记录 |
| `api_keys` | 平台级项目级 API Key 哈希与状态 |
| `storage_configs` | 加密后的 COS 配置及启用状态 |
| `audit_logs` | 管理业务操作审计 |
| `text_annotations` | 标题正文和 Tag 选区批注与文本上下文 |
| `work_comments` | 作品总体反馈 |
| `review_events` | 提交、退修、通过重新打开记录 |
| `api_keys` | 平台级/项目级 API Key 哈希 |
| `storage_configs` | 加密后的 COS 配置 |
| `audit_logs` | 管理业务审计 |
## 存储流程
## 状态计算
平台管理员在管理页新增 COS 配置。SecretId 和 SecretKey 使用 `COS_CONFIG_ENCRYPTION_KEY` 派生的 AES-256-GCM 密钥加密后写入数据库,读取配置的接口不会返回明文
作品状态为 `draft``pending``changes_requested``approved`。只有活动轮次可新增批注和作出验收决定;新轮次会锁定旧轮次。客户通过活动轮次后作品为已通过,退修后运营通过新轮次提交修改
启用配置前会在目标桶的 `.delivery-desk-check/` 路径依次上传、读取并删除一个临时对象。启用后,新上传文件写入
项目验收状态自动计算
```text
<path-prefix>/originals/YYYY/MM/<uuid>.<ext>
```
- 没有非草稿作品:`draft`
- 存在未通过作品:`reviewing`
- 所有非草稿作品通过:`completed`
- 人工归档:`archived`
未启用 COS 时,上传文件保存在本地 `uploads/`。图片 URL 按产品约定为公开随机地址,不提供对象级访问鉴权
完成项目为只读。新增作品、创建新轮次或由管理员重新打开作品时,项目恢复为验收中;已关闭或归档项目始终只读。开放反馈不会阻止通过;通过时仍为开放的反馈会标记为随该轮验收关闭,历史内容保留
## 验收状态
## 批注模型
作品状态为 `draft``pending``changes_requested``approved`。客户只能看到非草稿作品;客户可通过或要求修改,要求修改必须填写原因。已通过作品只能由平台管理员或所属组管理员填写原因后重新打开,历史事件保留
- 作品缩略图只展示现有坐标标记,不能新增坐标批注;点击标记会联动打开验收协作面板中的对应反馈
- 点击图片打开悬浮图片窗格;只有该窗格可以新增坐标批注,并支持原图查看、缩放和前后切换。点击窗格外会同时关闭图片窗格和验收协作面板。
- 标题、正文和 Tag 批注保存 `start_offset``end_offset``selected_text` 及前后文,提交时校验选区仍与轮次快照一致。
- `GET /api/works/:workId/annotations` 按轮次返回图片批注、文字批注、总体反馈和验收事件。
## Agent 安全上传
内置 Agent Skill 采用“发现目标 → 生成计划 → 人工确认 → 单次写入 → 读取核验”的两阶段流程。计划文件只保存目标 ID、待写内容和确认摘要不保存 API Key并写入已被 Git 忽略的 `tmp/` 目录。
新建作品以 `externalId` 保证幂等;新增验收轮次没有幂等键。轮次写入超时或响应不明确时,必须先重新读取作品状态,不能直接重试,以免重复创建轮次。
## 存储
平台管理员可在管理页保存和测试 COS 配置。SecretId/SecretKey 使用 `COS_CONFIG_ENCRYPTION_KEY` 派生的 AES-256-GCM 密钥加密,读取接口不返回明文。连接测试会上传、读取并删除临时对象。
外部 API 图片统一归一到当前活动 COS所有 URL 会先拒绝本机、私有网段、局域网和保留地址;与配置的 COS 公开域名或 CDN 域名同源时直接保存,其他公开 URL 经 DNS SSRF 防护、图片类型和 20 MB 大小校验后下载,并按内容哈希转存到 COS。没有活动 COS 配置时拒绝 URL 导入;全部图片准备成功后才创建作品或新验收轮次。该规则不自动追溯迁移历史图片内容。

View File

@@ -3,34 +3,34 @@
## 已完成
- 三类工作台角色、运营组隔离、账号管理和 7 天会话
- 项目、作品交付集、作品、版本和验收状态
- 手动多图上传、封面上传前拖拽排序及新版本
- 图片坐标批注、标题/正文批注、总体反馈和验收记录
- 客户项目链接、密码、姓名、期限和验收决定
- API Key、审计日志、COS 前端配置及连接测试
- 项目 → 作品 → 单方案验收轮次,以及项目级自动验收状态
- 手动多图上传、公开 URL API、封面上传前拖拽排序
- 缩略图只读标记、悬浮图片窗格、原图缩放与坐标批注
- 标题、正文和 Tag 选区批注、总体反馈、按作品聚合反馈和验收记录
- 批注回复线程、只能撤回本人反馈并保留撤回记录
- 客户项目链接、密码、姓名、访问期限和验收决定
- API Key、审计日志、COS 前端配置、连接测试、私有地址拦截及外部 URL 安全转存
- 内置 Agent 安全上传 Skill、双阶段确认脚本、回归测试和可分发 ZIP
- SQLite/PostgreSQL 双运行时、迁移验证和 Docker 部署
- 桌面端与移动端响应式页面
## 初版上线前仍需完成
以下需求尚未在代码中完整落地,不应在交付时宣称可用:
以下范围尚未完整落地,不应在交付时宣称可用:
- ZIP + CSV 批量导入和最多 100 个作品的异步批量 API
- `externalId` 幂等创建作品(项目和作品交付集暂未支持)
- webhook 与站内未读通知
- PDF 验收报告和最终原图 ZIP 导出
- 批注/回复的参考图片附件
- 项目、作品交付集、作品的回收站、归档恢复和永久删除规则
- 批注/回复的参考图片附件
- 项目作品的回收站、归档恢复和永久删除流程
- 已上传作品在所有阶段的图片重新排序
- 在线人员状态、实时变更通知和并发版本冲突保护
- HEIC/HEIF 转换、缩略图流水线和 EXIF 定位信息清理
- 自动化端到端浏览器测试及真实腾讯云、PostgreSQL 部署演练
- 在线人员状态、实时变更通知和并发冲突保护
- HEIC/HEIF 转换、多尺寸缩略图和 EXIF 定位信息清理
- 真实腾讯云、生产 PostgreSQL、HTTPS 和备份恢复演练
## 上线门槛
初版正式发布至少应满足:
1. 使用 PostgreSQL 和独立生产 COS 桶,完成一次备份恢复演练。
2. 轮换所有在聊天、截图或开发数据库中出现过的云密钥和临时密码。
1. 使用 PostgreSQL 和独立生产 COS 桶,完成备份恢复演练。
2. 轮换所有在聊天、截图或开发数据中出现过的云密钥和临时密码。
3. 在 HTTPS 域名下验证平台管理员、组管理员、光影叙事和客户四条核心流程。
4. 根据真实交付承诺,从上方未完成清单中选定必须进入初版的项目。
4. 根据真实交付承诺,从未完成清单中选定必须进入初版的项目。

View File

@@ -1,58 +1,44 @@
# API 接入指南
## 认证方式
## 认证
工作台网页使用 HttpOnly Cookie 会话。外部客户端使用:
工作台使用 HttpOnly Cookie外部客户端使用:
```http
Authorization: Bearer dd_live_xxx
```
API Key 明文只在创建时返回一次,数据库仅保存 SHA-256 哈希。平台管理员创建平台级 Key组管理员创建本组项目级 Key。失效或越权请求会返回 `401``403`
平台级 Key 可跨组创建和查询项目。项目级 Key 只能操作绑定项目,包括在该项目中新建作品和验收轮次。密钥明文只在创建时返回一次
## 主要路由
## Agent 安全上传 Skill
| 路由组 | 用途 |
|---|---|
| `/api/auth/*` | 登录、退出、当前账号、修改密码 |
| `/api/management/groups` | 运营组创建、改名、启停和管理员更换 |
| `/api/management/users` | 账号创建、改名、启停和重置密码 |
| `/api/management/api-keys` | API Key 创建、查询和吊销 |
| `/api/management/audit-logs` | 审计日志查询 |
| `/api/management/storage-configs` | COS 配置、连接测试和启用 |
| `/api/projects` | 项目创建、查询和编辑 |
| `/api/projects/:projectId/collections` | 作品交付集创建、查询和编辑 |
| `/api/notes` | 作品查询与创建 |
| `/api/notes/:noteId/versions` | 创建作品新版本 |
| `/api/notes/:noteId/status` | 草稿与待验收状态切换 |
| `/api/notes/:noteId/text-annotations` | 标题/正文批注 |
| `/api/images/:imageId/annotations` | 图片坐标批注 |
| `/api/review/:slug/*` | 客户登录、浏览、反馈与验收 |
| `/api/health` | 数据库就绪检查 |
项目内置 `.agents/skills/upload-delivery-desk-work`,用于引导 Agent 精确定位运营组、项目和作品后创建作品或提交新验收轮次。它强制执行“发现 → 生成计划 → 操作者确认 → 单次提交 → 回读验证”,不允许根据名称猜测目标。
## 查询运营组、项目、作品交付集和作品
更新 Skill 后重新生成分发包:
调用方不需要预先知道数据库 ID。使用 API Key 按顺序查询:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1
```
压缩包输出到 `skill-packages/upload-delivery-desk-work.zip`。API Key 只能通过 `DELIVERY_DESK_API_KEY` 环境变量提供,不应写入 Skill、计划文件或命令参数。
## 发现资源
```bash
# 返回 Key 有权访问的项目,响应包含 group_id、group_name 和项目 id
# 查询 Key 访问的项目,响应包含 group_id、group_name 和项目 id
curl http://localhost:3010/api/projects \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
# 查询项目中的作品交付集
curl http://localhost:3010/api/projects/1/collections \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
# 查询交付集中的作品,响应包含作品 id、external_id 和 version_number
curl "http://localhost:3010/api/notes?collectionId=1" \
# 查询项目作品
curl http://localhost:3010/api/projects/1/works \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
```
项目级 Key 的项目列表只会返回绑定项目;平台级 Key 可以查询全部运营组的项目。创建作品时只传 `collectionId`,服务会据此确定项目和运营组并校验权限,不需要重复传递 `projectId``groupId`
调用方不再需要作品交付集 ID。`externalId` 在项目内唯一,可用于安全重试和找回作品
## 创建项目
平台级 API Key 可以指定目标运营组。项目级 Key 不能创建项目。
只有平台级 Key 可以创建项目。
```bash
curl -X POST http://localhost:3010/api/projects \
@@ -61,81 +47,99 @@ curl -X POST http://localhost:3010/api/projects \
-d '{"name":"7 月内容计划","slug":"july-content","groupId":1,"client_description":"客户可见说明"}'
```
`slug` 仅支持小写字母、数字和连字符,并作为客户验收链接的一部分。
## 创建作品
## 创建作品交付集
JSON 请求中的 `images` 为 130 个公开 HTTP/HTTPS URL且平台必须已有活动 COS 配置。数组顺序就是展示顺序,第一张为封面。
- 所有 URL 都会先拒绝 `localhost`、本机、私有网段、局域网和保留地址;即使域名与配置同源也不会绕过这项检查。
- URL 与活动 COS 的公开域名或 CDN 域名同源时直接保存,不重复上传。
- 其他域名的图片会由服务端下载并转存到活动 COS最终入库 URL 来自该 COS。
- 外部图片单张不得超过 20 MB必须返回受支持的图片类型本机、内网、保留地址和非标准端口会被拒绝。
```bash
curl -X POST http://localhost:3010/api/projects/1/collections \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"2026 年 7 月交付","client_description":"本月交付内容"}'
```
## 上传作品
外部客户端使用 JSON 创建作品,`images` 直接传入 130 个公开可读的 HTTP/HTTPS 图片 URL。服务只保存 URL不会下载图片或再次上传到 COS。数组顺序就是展示顺序第一张为封面。
```bash
curl -X POST http://localhost:3010/api/notes \
curl -X POST http://localhost:3010/api/projects/1/works \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"collectionId": 1,
"externalId": "client-work-20260721-001",
"title": "作品标题",
"description": "正文内容",
"tags": ["用户填写的标签原文"],
"images": [
"https://cdn.example.com/works/01.jpg",
"https://cdn.example.com/works/02.jpg"
]
"externalId":"client-work-20260722-001",
"title":"作品标题",
"description":"正文内容",
"tags":["#夏日","用户原文"],
"images":["https://cdn.example.com/01.jpg","https://cdn.example.com/02.jpg"]
}'
```
`externalId` 是调用方在当前作品交付集内的作品唯一标识,支持字母、数字、点、下划线、冒号和横线,最长 128 位。相同 `collectionId + externalId` 的重复请求不会重复创建作品,而会以 `200` 返回原作品并包含 `"idempotent": true`创建成功响应中的 `id` 是后续上传版本所需的 `workId`;如果调用方丢失了该 ID可以通过 `GET /api/notes?collectionId=1&externalId=client-work-20260721-001` 找回
相同 `projectId + externalId` 的重不会重复创建,响应包含 `idempotent: true`异源图片成功转存后不再依赖原地址长期可用;任意图片校验或转存失败时不会创建作品记录
URL 图片不会进入当前配置的 COS也不会由服务检查其内容或长期可用性因此调用方需要保证链接公开、稳定且确实指向图片。工作台手动上传仍接受 JPEG、PNG、GIF、WebP 和 AVIF。标签按原文保存和展示不会自动添加 `#` 或拆分为标签库。
## 创建新验收轮次
## 创建新版本
每轮只能提交一个方案。标题、正文、标签和图片会形成不可修改的轮次快照;新轮次自动锁定上一轮。
```bash
curl -X POST http://localhost:3010/api/notes/12/versions \
curl -X POST http://localhost:3010/api/works/12/rounds \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "修改后的标题",
"description": "修改后的正文",
"tags": ["修改后的标签原文"],
"images": [
"https://cdn.example.com/works/v2-01.jpg",
"https://cdn.example.com/works/v2-02.jpg"
]
"title":"修改后的标题",
"description":"修改后的正文",
"tags":["#第二轮"],
"images":["https://cdn.example.com/round-2.jpg"]
}'
```
批注绑定作品版本或具体图片,不会因新版本覆盖历史验收证据。
## 查询作品与全部反馈
## Python 冒烟脚本
```bash
# 当前轮或指定轮
curl "http://localhost:3010/api/works/12?round=2" \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
项目自带 `tests/api_create_work.py`,只使用 Python 标准库。推荐通过环境变量提供项目级 API Key
```powershell
$env:DELIVERY_DESK_API_KEY = 'dd_live_xxx'
python tests/api_create_work.py --project-id 1 --collection-id 1
# 为已有作品创建新版本
python tests/api_create_work.py --project-id 1 --collection-id 1 --work-id 12
# 按轮返回该作品全部反馈和验收事件
curl http://localhost:3010/api/works/12/annotations \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
```
如果项目级 Key 只能访问一个项目,并且项目下只有一个作品交付集,可以省略两个 ID。脚本也支持不传 Key、改用 `--username` 后交互输入密码。
## 错误响应
错误统一以 JSON 返回:
标题、正文和 Tag 选区批注使用:
```json
{ "error": "错误说明" }
{
"round_number": 2,
"target": "description",
"start_offset": 4,
"end_offset": 8,
"selected_text": "选中文字",
"content": "这里需要调整"
}
```
常见状态码:`400` 输入无效、`401` 未认证、`403` 越权、`404` 资源不存在、`409` 唯一性或状态冲突、`500` 服务端错误。
服务会校验偏移量和所选文字是否匹配当前轮次快照。历史轮次或已完成项目返回 `409`
批注、文字批注和总体反馈都可回复,类型分别为 `image_annotation``text_annotation``comment`
```http
POST /api/works/:workId/feedback/:type/:feedbackId/replies
POST /api/works/:workId/feedback/:type/:feedbackId/withdraw
```
撤回只允许原作者执行,不会删除数据库记录。客户入口在路径前增加 `/api/review/:slug`,并执行相同的项目归属与身份校验。
## 客户验收
客户输入项目密码和姓名后使用 Cookie 调用:
```bash
curl -X POST http://localhost:3010/api/review/july-content/works/12/decision \
-b cookies.txt \
-H "Content-Type: application/json" \
-d '{"round_number":2,"decision":"approved"}'
```
`decision``approved``changes_requested`;退修必须填写 `reason`。只允许决定活动轮次。
客户侧全部反馈接口为 `/api/review/:slug/works/:workId/annotations`,仍会校验客户会话绑定的项目。
## 兼容接口
旧的 `/api/notes``/api/notes/:id/versions``/api/notes/:id/review-rounds``/api/projects/:id/collections` 暂保留一个兼容周期。旧交付集 URL 会跳转到项目页;旧多候选稿请求会返回 `400`,不会再创建多方案轮次。新接入必须使用项目、作品和轮次接口。
错误响应均包含 `{ "error": "错误说明" }`。常见状态码:`400` 输入无效或地址被安全策略拒绝、`401` 未认证、`403` 越权、`404` 不存在、`409` 状态冲突或未启用 COS、`413` 图片超过 20 MB、`422` 外部图片无法下载或内容无效、`502` 转存 COS 失败。

View File

@@ -47,7 +47,22 @@ docker compose logs --tail=100 app
连接测试会真实执行一次上传、读取和删除,因此密钥至少需要目标前缀的这三项权限。测试对象会尽力清理;请求中断时可检查 `<path-prefix>/.delivery-desk-check/` 是否残留临时文件。
COS 使用公开 URL。必须关闭桶列表功能并使用不可枚举对象名拿到 URL 的人可以直接访问文件。
COS 使用公开 URL。公共访问域名和 CDN 域名必须能够解析到公网地址,本机、私有网段、局域网或保留地址会在保存配置时被拒绝。必须关闭桶列表功能并使用不可枚举对象名;拿到 URL 的人可以直接访问文件。
JSON URL 导入依赖活动 COS 配置。同一 COS/CDN 域名的图片直接使用;其他域名会下载并按内容哈希写入 `<path-prefix>/imports/`。服务会拒绝内网地址、非图片响应和超过 20 MB 的文件,因此部署网络必须允许访问确需导入的公开图片源。
使用 `pnpm server:prod` 运行本地 API 时不会监听源码变化;后端代码更新后必须重启进程。日常开发应使用 `pnpm server:dev``pnpm dev`
## Agent 上传 Skill 维护
Agent 通过 API 新建作品或提交验收轮次时,使用 `.agents/skills/upload-delivery-desk-work`。API Key 只通过 `DELIVERY_DESK_API_KEY` 环境变量注入,不写入计划文件、文档或 Git。
```powershell
python tests/test_upload_skill.py
powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1
```
打包脚本会先运行回归测试,再生成 `skill-packages/upload-delivery-desk-work.zip`,并校验 ZIP 根目录直接包含 `SKILL.md`。计划文件写入已忽略的 `tmp/`;接口或层级变化后必须同步更新 Skill、测试和分发包。
## 数据备份与恢复
@@ -63,7 +78,11 @@ pnpm install --frozen-lockfile
pnpm check
pnpm lint
pnpm build
pnpm test:review-rounds
pnpm test:collection-status
pnpm test:postgres-runtime
pnpm db:postgres:validate
python tests/test_upload_skill.py
```
正式切换前还应验证管理员首次改密、客户访问门禁、COS 上传、客户批注与验收、数据库备份及 HTTPS Cookie。

View File

@@ -14,6 +14,8 @@
"db:postgres:migrate": "tsx scripts/migrate-sqlite-to-postgres.ts",
"db:postgres:validate": "tsx scripts/validate-postgres-migration.ts",
"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": "concurrently \"npm run client:dev\" \"npm run server:dev\""
},
"dependencies": {

View File

@@ -13,7 +13,7 @@ if (!fs.existsSync(sqlitePath)) throw new Error(`SQLite 数据库不存在:${s
const tables = [
'operation_groups', 'users', 'projects', 'collections', 'notes', 'images', 'annotations',
'work_comments', 'work_versions', 'review_events', 'sessions', 'customer_sessions',
'work_comments', 'feedback_replies', 'work_versions', 'review_rounds', 'review_events', 'sessions', 'customer_sessions',
'audit_logs', 'api_keys', 'storage_configs',
] as const;
const booleanColumns: Record<string, Set<string>> = {
@@ -26,10 +26,12 @@ const client = new pg.Client({ connectionString: databaseUrl, ssl: process.env.P
try {
await client.connect();
await client.query(fs.readFileSync(schemaPath, 'utf8'));
const schema = fs.readFileSync(schemaPath, 'utf8');
await client.query(schema);
const existing = Number((await client.query('SELECT COUNT(*)::int AS count FROM operation_groups')).rows[0].count);
if (existing && !replace) throw new Error('PostgreSQL 已有数据。确认覆盖时请显式添加 --replace');
await client.query('BEGIN');
await client.query('DROP INDEX IF EXISTS idx_work_versions_one_per_round');
if (replace) await client.query(`TRUNCATE ${[...tables].reverse().map((table) => `"${table}"`).join(', ')} RESTART IDENTITY CASCADE`);
for (const table of tables) {
@@ -45,6 +47,16 @@ try {
}
if (rows.length) await client.query(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), (SELECT MAX(id) FROM "${table}"), true)`);
}
const collectionStatusRepair = schema.match(/-- COLLECTION_STATUS_REPAIR_START([\s\S]+?)-- COLLECTION_STATUS_REPAIR_END/)?.[1];
const reviewRoundRepair = schema.match(/-- REVIEW_ROUND_REPAIR_START([\s\S]+?)-- REVIEW_ROUND_REPAIR_END/)?.[1];
const singleSchemeRepair = schema.match(/-- SINGLE_SCHEME_REPAIR_START([\s\S]+?)-- SINGLE_SCHEME_REPAIR_END/)?.[1];
if (!collectionStatusRepair) throw new Error('PostgreSQL schema 缺少作品交付集状态修复脚本');
if (!reviewRoundRepair) throw new Error('PostgreSQL schema 缺少验收轮次修复脚本');
if (!singleSchemeRepair) throw new Error('PostgreSQL schema 缺少单方案轮次修复脚本');
await client.query(reviewRoundRepair);
await client.query(singleSchemeRepair);
await client.query('CREATE UNIQUE INDEX idx_work_versions_one_per_round ON work_versions(review_round_id) WHERE review_round_id IS NOT NULL');
await client.query(collectionStatusRepair);
await client.query('COMMIT');
process.stdout.write(`迁移完成:${tables.length} 张表已从 ${sqlitePath} 导入 PostgreSQL\n`);
} catch (error) {

View File

@@ -0,0 +1,57 @@
$ErrorActionPreference = 'Stop'
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$skillName = 'upload-delivery-desk-work'
$sourcePath = Join-Path $repositoryRoot ".agents\skills\$skillName"
$packageDirectory = Join-Path $repositoryRoot 'skill-packages'
$packagePath = Join-Path $packageDirectory "$skillName.zip"
if (-not (Test-Path -LiteralPath (Join-Path $sourcePath 'SKILL.md'))) {
throw "Skill not found: $sourcePath"
}
$previousNoBytecode = $env:PYTHONDONTWRITEBYTECODE
try {
$env:PYTHONDONTWRITEBYTECODE = '1'
python (Join-Path $repositoryRoot 'tests\test_upload_skill.py')
if ($LASTEXITCODE -ne 0) {
throw 'Upload Skill regression tests failed.'
}
} finally {
$env:PYTHONDONTWRITEBYTECODE = $previousNoBytecode
}
$runtimeFiles = Get-ChildItem -LiteralPath $sourcePath -Recurse -Force | Where-Object {
$_.Name -eq '__pycache__' -or $_.Extension -in @('.pyc', '.pyo')
}
if ($runtimeFiles) {
throw 'Skill contains Python runtime cache. Remove it before packaging.'
}
New-Item -ItemType Directory -Force -Path $packageDirectory | Out-Null
if (Test-Path -LiteralPath $packagePath) {
Remove-Item -LiteralPath $packagePath -Force
}
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::CreateFromDirectory(
$sourcePath,
$packagePath,
[System.IO.Compression.CompressionLevel]::Optimal,
$false
)
$archive = [System.IO.Compression.ZipFile]::OpenRead($packagePath)
try {
$entries = @($archive.Entries | ForEach-Object { $_.FullName.Replace('\', '/') })
if ($entries -notcontains 'SKILL.md') {
throw 'Packaged Skill must contain SKILL.md at the ZIP root.'
}
if ($entries | Where-Object { $_ -like "$skillName/*" }) {
throw 'Packaged Skill contains an extra top-level directory.'
}
} finally {
$archive.Dispose()
}
Write-Output $packagePath

View File

@@ -4,7 +4,8 @@ process.env.INITIAL_ADMIN_PASSWORD = 'AdminTest123!';
process.env.COS_CONFIG_ENCRYPTION_KEY = 'test-only-encryption-key-at-least-32-characters';
const { default: app } = await import('../api/app.js');
const { closeDatabase } = await import('../api/database.js');
const { database, closeDatabase } = await import('../api/database.js');
const { encryptSecret } = await import('../api/configCrypto.js');
const server = app.listen(0, '127.0.0.1');
await new Promise<void>((resolve) => server.once('listening', resolve));
const address = server.address();
@@ -28,7 +29,6 @@ try {
expectStatus(login.response.status, 200, '平台管理员登录');
const adminCookie = login.response.headers.get('set-cookie')?.split(';')[0];
if (!adminCookie) throw new Error('登录未返回会话 Cookie');
const group = await request('/api/management/groups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '测试运营组', username: 'test_manager', display_name: '测试管理员', password: 'Manager123!' }) }, adminCookie);
expectStatus(group.response.status, 201, '创建运营组');
const groupId = Number((group.body as { id: number }).id);
@@ -76,9 +76,47 @@ try {
expectStatus(project.response.status, 201, '创建项目', project.body);
if((project.body as {group_name?:string}).group_name!=='已更名运营组')throw new Error('项目接口未返回所属运营组名称');
const projectId = Number((project.body as { id: number }).id);
const missingStorage=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'缺少 COS 配置',images:['https://cdn.example.com/missing.jpg']})},adminCookie);
expectStatus(missingStorage.response.status,409,'没有活动 COS 时拒绝 URL 导入',missingStorage.body);
const worksWithoutStorage=await request(`/api/projects/${projectId}/works`,{},adminCookie);
if((worksWithoutStorage.body as unknown[]).length!==0)throw new Error('URL 导入失败后仍创建了作品记录');
const blockedPrivateStorage=await request('/api/management/storage-configs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({region:'ap-guangzhou',bucket:'blocked-private-1234567890',public_base_url:'http://127.0.0.1:8080',cdn_domain:'',path_prefix:'delivery-desk',secret_id:'test-secret-id',secret_key:'test-secret-key'})},adminCookie);
expectStatus(blockedPrivateStorage.response.status,400,'拒绝私有地址作为对象存储访问域名',blockedPrivateStorage.body);
await database.insertId("INSERT INTO storage_configs (region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted,status,test_status,created_by) VALUES (?,?,?,?,?,?,?,?,?,?)", ['ap-guangzhou','runtime-test-1234567890','https://runtime-test-1234567890.cos.ap-guangzhou.myqcloud.com','https://cdn.example.com','delivery-desk',encryptSecret('test-secret-id'),encryptSecret('test-secret-key'),'active','passed',1]);
const privateImageUrls=['http://localhost/private.jpg','http://127.0.0.1/private.jpg','http://10.0.0.1/private.jpg','http://100.64.0.1/private.jpg','http://169.254.0.1/private.jpg','http://172.16.0.1/private.jpg','http://192.168.1.1/private.jpg','http://[::1]/private.jpg','http://[fd00::1]/private.jpg'];
for(const [index,imageUrl] of privateImageUrls.entries()){
const blockedPrivateImage=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:`禁止私有图片 ${index+1}`,images:[imageUrl]})},adminCookie);
expectStatus(blockedPrivateImage.response.status,400,`拒绝私有图片地址 ${imageUrl}`,blockedPrivateImage.body);
}
const worksAfterPrivateImages=await request(`/api/projects/${projectId}/works`,{},adminCookie);
if((worksAfterPrivateImages.body as unknown[]).length!==0)throw new Error('私有图片地址被拒绝后仍创建了作品记录');
const otherProject=await request('/api/projects',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'同组隔离项目',slug:'isolated-project',client_description:'不应被项目 Key 看见',groupId})},adminCookie);
expectStatus(otherProject.response.status,201,'创建同组隔离项目',otherProject.body);
const otherProjectId=Number((otherProject.body as {id:number}).id);
const otherWork=await request(`/api/projects/${otherProjectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'其他项目作品',images:['https://cdn.example.com/isolated.jpg']})},adminCookie);
expectStatus(otherWork.response.status,201,'创建其他项目作品',otherWork.body);
const otherWorkId=Number((otherWork.body as {id:number}).id);
const nativeFetch=globalThis.fetch;
const {default:COS}=await import('cos-nodejs-sdk-v5');
const cosPrototype=COS.prototype as unknown as {putObject:(...args:unknown[])=>unknown};
const nativePutObject=cosPrototype.putObject;
let importedStorageKey='';
try{
globalThis.fetch=(async(input:RequestInfo|URL,init?:RequestInit)=>{
const target=input instanceof Request?input.url:String(input);
if(target==='https://93.184.216.34/source.png')return new Response(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64'),{status:200,headers:{'Content-Type':'image/png'}});
return nativeFetch(input,init);
}) as typeof fetch;
cosPrototype.putObject=(async(options:unknown)=>{importedStorageKey=String((options as {Key:string}).Key);return{statusCode:200}}) as typeof cosPrototype.putObject;
const importedWork=await request(`/api/projects/${otherProjectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'异源转存作品',images:['https://93.184.216.34/source.png']})},adminCookie);
expectStatus(importedWork.response.status,201,'异源图片转存 COS',importedWork.body);
const importedDetail=await request(`/api/works/${Number((importedWork.body as {id:number}).id)}`,{},adminCookie);
const importedImage=(importedDetail.body as {images:Array<{url:string;storage_provider:string;storage_key:string}>}).images[0];
if(!importedStorageKey.startsWith('delivery-desk/imports/')||importedImage.url!==`https://cdn.example.com/${importedStorageKey}`||importedImage.storage_provider!=='tencent_cos')throw new Error('异源图片没有归一到当前 COS');
}finally{
globalThis.fetch=nativeFetch;
cosPrototype.putObject=nativePutObject;
}
const access = await request(`/api/projects/${projectId}/customer-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, password: 'Review123!' }) }, adminCookie);
expectStatus(access.response.status, 200, '配置客户访问');
@@ -86,6 +124,7 @@ try {
const collection = await request(`/api/projects/${projectId}/collections`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '第一阶段', client_description: '验收阶段' }) }, adminCookie);
expectStatus(collection.response.status, 201, '创建作品交付集');
const collectionId=Number((collection.body as {id:number}).id);
if((collection.body as {status:string}).status!=='draft')throw new Error('空作品交付集未初始化为待提交');
const projectKey=await request('/api/management/api-keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'项目接入测试 Key',project_id:projectId})},newAdminCookie);
expectStatus(projectKey.response.status,201,'创建项目级 API Key',projectKey.body);
@@ -97,6 +136,8 @@ try {
if((visibleProjects.body as Array<{id:number}>).length!==1||Number((visibleProjects.body as Array<{id:number}>)[0].id)!==projectId)throw new Error('项目级 Key 未严格隔离到绑定项目');
const forbiddenProject=await request(`/api/projects/${otherProjectId}`,{headers:bearerHeaders});
expectStatus(forbiddenProject.response.status,403,'项目级 Key 拒绝访问其他项目',forbiddenProject.body);
const forbiddenFeedback=await request(`/api/works/${otherWorkId}/annotations`,{headers:bearerHeaders});
expectStatus(forbiddenFeedback.response.status,403,'项目级 Key 拒绝读取其他项目作品反馈',forbiddenFeedback.body);
const visibleCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
expectStatus(visibleCollections.response.status,200,'项目级 Key 查询作品交付集',visibleCollections.body);
if(!(visibleCollections.body as Array<{id:number}>).some((item)=>Number(item.id)===collectionId))throw new Error('项目级 Key 未返回目标作品交付集');
@@ -104,6 +145,11 @@ try {
const createdWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify(createWorkBody)});
expectStatus(createdWork.response.status,201,'JSON URL 创建作品',createdWork.body);
const workId=Number((createdWork.body as {id:number}).id);
const reviewingCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
const reviewingCollection=(reviewingCollections.body as Array<{id:number;status:string;work_count:number}>).find((item)=>Number(item.id)===collectionId);
if(reviewingCollection?.status!=='reviewing'||Number(reviewingCollection.work_count)!==1)throw new Error('新增待验收作品后,作品交付集未进入验收中');
const reviewingProject=await request(`/api/projects/${projectId}`,{headers:bearerHeaders});
if((reviewingProject.body as {review_status:string}).review_status!=='reviewing')throw new Error('新增待验收作品后,项目未进入验收中');
const repeatedWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify(createWorkBody)});
expectStatus(repeatedWork.response.status,200,'externalId 幂等创建',repeatedWork.body);
if(Number((repeatedWork.body as {id:number}).id)!==workId||!(repeatedWork.body as {idempotent?:boolean}).idempotent)throw new Error('externalId 重复请求创建了不同作品');
@@ -115,24 +161,131 @@ try {
if(Number((newVersion.body as {version_number:number}).version_number)!==2)throw new Error('作品版本号未递增');
const workDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
expectStatus(workDetail.response.status,200,'读取新版本作品',workDetail.body);
const firstRound=(workDetail.body as {rounds:Array<{version_number:number;round_status:string;completion_reason:string}>}).rounds.find((item)=>item.version_number===1);
if(firstRound?.round_status!=='completed'||firstRound.completion_reason!=='superseded')throw new Error('新验收轮次未自动收口旧轮次');
const currentImages=(workDetail.body as {images:Array<{url:string;storage_provider:string}>}).images;
if(currentImages.length!==1||currentImages[0].url!=='https://cdn.example.com/runtime-v2.jpg'||currentImages[0].storage_provider!=='external')throw new Error('新版本图片没有按外部 URL 保存');
const revokeProjectKey=await request(`/api/management/api-keys/${projectKeyId}`,{method:'DELETE'},newAdminCookie);
expectStatus(revokeProjectKey.response.status,204,'吊销项目级 API Key');
if(currentImages.length!==1||currentImages[0].url!=='https://cdn.example.com/runtime-v2.jpg'||currentImages[0].storage_provider!=='tencent_cos')throw new Error('同源 COS 图片没有直接复用');
const reviewLogin = await request('/api/review/postgres-runtime-test/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer_name: '客户测试', password: 'Review123!' }) });
expectStatus(reviewLogin.response.status, 200, '客户登录');
const reviewCookie = reviewLogin.response.headers.get('set-cookie')?.split(';')[0];
const reviewProject = await request('/api/review/postgres-runtime-test/project', {}, reviewCookie);
expectStatus(reviewProject.response.status, 200, '客户项目读取');
const approveV2=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:2,decision:'approved'})},reviewCookie);
expectStatus(approveV2.response.status,200,'客户通过作品',approveV2.body);
const completedCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
const completedCollection=(completedCollections.body as Array<{id:number;status:string;completed_at:string|null;approved_count:number}>).find((item)=>Number(item.id)===collectionId);
if(completedCollection?.status!=='completed'||!completedCollection.completed_at||Number(completedCollection.approved_count)!==1)throw new Error('全部作品通过后,作品交付集未自动完成');
const readonlyComment=await request(`/api/review/postgres-runtime-test/works/${workId}/comments`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:'完成后不应写入'})},reviewCookie);
expectStatus(readonlyComment.response.status,409,'验收完毕后客户只读',readonlyComment.body);
const completedWorkDetail=await request(`/api/review/postgres-runtime-test/works/${workId}`,{},reviewCookie);
expectStatus(completedWorkDetail.response.status,200,'完成后读取作品',completedWorkDetail.body);
if((completedWorkDetail.body as {project:{review_status:string}}).project.review_status!=='completed')throw new Error('作品详情未返回项目验收完成状态');
const completedProject=await request(`/api/projects/${projectId}`,{headers:bearerHeaders});
if((completedProject.body as {review_status:string}).review_status!=='completed')throw new Error('全部作品通过后,项目接口未返回验收完成');
const reopenApproved=await request(`/api/notes/${workId}/reopen`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason:'补充复核'})},newAdminCookie);
expectStatus(reopenApproved.response.status,200,'组管理员重新打开已通过作品',reopenApproved.body);
const reopenedByAdmin=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
if((reopenedByAdmin.body as Array<{id:number;status:string}>).find((item)=>Number(item.id)===collectionId)?.status!=='reviewing')throw new Error('管理员重新打开作品后,作品交付集未回到验收中');
const approveReopened=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:2,decision:'approved'})},reviewCookie);
expectStatus(approveReopened.response.status,200,'客户通过重新打开的作品',approveReopened.body);
const versionThree=await request(`/api/notes/${workId}/versions`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({title:'接口作品 V3',description:'完成后追加版本',tags:['API 测试'],images:['https://cdn.example.com/runtime-v3.jpg']})});
expectStatus(versionThree.response.status,201,'完成后创建新版本',versionThree.body);
const reopenedCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
const reopenedCollection=(reopenedCollections.body as Array<{id:number;status:string;completed_at:string|null}>).find((item)=>Number(item.id)===collectionId);
if(reopenedCollection?.status!=='reviewing'||reopenedCollection.completed_at!==null)throw new Error('新版本未将作品交付集重新打开为验收中');
const requestChanges=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:3,decision:'changes_requested',reason:'请调整第三轮'})},reviewCookie);
expectStatus(requestChanges.response.status,200,'客户要求修改',requestChanges.body);
const closedRoundComment=await request(`/api/works/${workId}/comments`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({content:'退修轮次不应继续写入'})});
expectStatus(closedRoundComment.response.status,409,'退修轮次禁止新增总体反馈',closedRoundComment.body);
const closedRoundDecision=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:3,decision:'approved'})},reviewCookie);
expectStatus(closedRoundDecision.response.status,409,'退修轮次不可再次通过',closedRoundDecision.body);
const roundFour=await request(`/api/works/${workId}/rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({title:'接口作品 V4',description:'第四轮',tags:['API 测试'],images:['https://cdn.example.com/runtime-v4.jpg']})});
expectStatus(roundFour.response.status,201,'创建单方案第 4 轮',roundFour.body);
const multiRound=await request(`/api/notes/${workId}/review-rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({candidates:[{title:'方案 A',images:['https://cdn.example.com/a.jpg']},{title:'方案 B',images:['https://cdn.example.com/b.jpg']}]})});
expectStatus(multiRound.response.status,400,'拒绝一轮多个方案',multiRound.body);
const historicalClientAnnotation=await request(`/api/review/postgres-runtime-test/works/${workId}/text-annotations`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:3,target:'title',start_offset:0,end_offset:4,selected_text:'接口作品',content:'历史轮次不应写入'})},reviewCookie);
expectStatus(historicalClientAnnotation.response.status,409,'客户不可批注历史轮次',historicalClientAnnotation.body);
const mismatchedSelection=await request(`/api/works/${workId}/text-annotations`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({round_number:4,target:'title',start_offset:0,end_offset:4,selected_text:'错误文字',content:'不应写入'})});
expectStatus(mismatchedSelection.response.status,400,'拒绝与内容快照不匹配的文字选区',mismatchedSelection.body);
const textAnnotation=await request(`/api/works/${workId}/text-annotations`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({round_number:4,target:'title',start_offset:0,end_offset:4,selected_text:'接口作品',content:'标题选区批注'})});
expectStatus(textAnnotation.response.status,201,'创建标题选区批注',textAnnotation.body);
const textAnnotationId=Number((textAnnotation.body as {id:number}).id);
const tagAnnotation=await request(`/api/review/postgres-runtime-test/works/${workId}/text-annotations`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:4,target:'tags',start_offset:0,end_offset:6,selected_text:'API 测试',content:'Tag 选区批注'})},reviewCookie);
expectStatus(tagAnnotation.response.status,201,'客户创建 Tag 选区批注',tagAnnotation.body);
const feedbackReply=await request(`/api/works/${workId}/feedback/text_annotation/${textAnnotationId}/replies`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({content:'已记录这条意见'})});
expectStatus(feedbackReply.response.status,201,'回复文字批注',feedbackReply.body);
const feedback=await request(`/api/works/${workId}/annotations`,{headers:bearerHeaders});
expectStatus(feedback.response.status,200,'按作品 ID 获取全部反馈',feedback.body);
const roundFeedback=(feedback.body as {rounds:Array<{round_number:number;text_annotations:Array<unknown>;feedback_replies:Array<unknown>}>}).rounds.find((item)=>item.round_number===4);
if(roundFeedback?.text_annotations.length!==2||roundFeedback.feedback_replies.length!==1)throw new Error('作品反馈聚合未返回标题、Tag 批注及其回复');
const optimizationContext=await request(`/api/works/${workId}/optimization-context?round=4`,{headers:bearerHeaders});
expectStatus(optimizationContext.response.status,200,'获取内容优化上下文',optimizationContext.body);
const optimizationBody=optimizationContext.body as {content:{title:string;tags:string[];images:Array<{url:string}>};feedback:{text_annotations:Array<{target:string;replies:Array<unknown>}>}};
if(optimizationBody.content.title!=='接口作品 V4'||optimizationBody.content.tags[0]!=='API 测试'||optimizationBody.content.images[0]?.url!=='https://cdn.example.com/runtime-v4.jpg')throw new Error('内容优化上下文缺少当前轮次图文快照');
if(optimizationBody.feedback.text_annotations.length!==2||optimizationBody.feedback.text_annotations.find((item)=>item.target==='title')?.replies.length!==1)throw new Error('内容优化上下文未组合有效批注与回复');
const withdrawText=await request(`/api/works/${workId}/feedback/text_annotation/${textAnnotationId}/withdraw`,{method:'POST',headers:bearerHeaders});
expectStatus(withdrawText.response.status,200,'本人留痕撤回文字批注',withdrawText.body);
const afterWithdraw=await request(`/api/works/${workId}/annotations`,{headers:bearerHeaders});
const withdrawnItem=(afterWithdraw.body as {rounds:Array<{round_number:number;text_annotations:Array<{id:number;withdrawn_at:string|null}>}>}).rounds.find((item)=>item.round_number===4)?.text_annotations.find((item)=>item.id===textAnnotationId);
if(!withdrawnItem?.withdrawn_at)throw new Error('撤回批注未在作品聚合接口中保留记录');
const actionableContext=await request(`/api/works/${workId}/optimization-context?round=4`,{headers:bearerHeaders});
if((actionableContext.body as {feedback:{text_annotations:Array<{id:number}>}}).feedback.text_annotations.some((item)=>item.id===textAnnotationId))throw new Error('内容优化上下文默认返回了已撤回批注');
const historyContext=await request(`/api/works/${workId}/optimization-context?round=4&include_history=true`,{headers:bearerHeaders});
if(!(historyContext.body as {feedback:{text_annotations:Array<{id:number}>}}).feedback.text_annotations.some((item)=>item.id===textAnnotationId))throw new Error('内容优化上下文无法按需返回历史批注');
await database.execute("UPDATE projects SET status='closed' WHERE id=?",[projectId]);
const closedProjectRound=await request(`/api/works/${workId}/rounds`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'不应创建',images:['https://cdn.example.com/closed.jpg']})},adminCookie);
expectStatus(closedProjectRound.response.status,409,'已关闭项目禁止创建新轮次',closedProjectRound.body);
const closedProjectDecision=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:4,decision:'approved'})},reviewCookie);
expectStatus(closedProjectDecision.response.status,409,'已关闭项目禁止客户验收写入',closedProjectDecision.body);
await database.execute("UPDATE projects SET status='active' WHERE id=?",[projectId]);
const approveRoundFour=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:4,decision:'approved'})},reviewCookie);
expectStatus(approveRoundFour.response.status,200,'客户通过第 4 轮',approveRoundFour.body);
const secondWork=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({externalId:'runtime-client-work-002',title:'完成后新增作品',description:'验证部分通过',tags:['API 测试'],images:['https://cdn.example.com/runtime-second.jpg']})});
expectStatus(secondWork.response.status,201,'完成后新增作品',secondWork.body);
const secondWorkId=Number((secondWork.body as {id:number}).id);
const partialCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
const partialCollection=(partialCollections.body as Array<{id:number;status:string;work_count:number;approved_count:number}>).find((item)=>Number(item.id)===collectionId);
if(partialCollection?.status!=='reviewing'||Number(partialCollection.work_count)!==2||Number(partialCollection.approved_count)!==1)throw new Error('完成后新增作品未恢复验收中或进度统计错误');
const reopenedProject=await request(`/api/projects/${projectId}`,{headers:bearerHeaders});
if((reopenedProject.body as {review_status:string}).review_status!=='reviewing')throw new Error('完成后新增作品未将项目恢复为验收中');
const approveSecond=await request(`/api/review/postgres-runtime-test/works/${secondWorkId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:1,decision:'approved'})},reviewCookie);
expectStatus(approveSecond.response.status,200,'客户通过新增作品',approveSecond.body);
const forbiddenDraft=await request(`/api/notes/${workId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'draft'})});
expectStatus(forbiddenDraft.response.status,409,'普通写入不能绕过重新打开规则',forbiddenDraft.body);
const reopenForDraft=await request(`/api/notes/${workId}/reopen`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason:'验证退回草稿后的集合状态'})},newAdminCookie);
expectStatus(reopenForDraft.response.status,200,'组管理员重新打开第一件作品',reopenForDraft.body);
const reopenSecondForDraft=await request(`/api/notes/${secondWorkId}/reopen`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason:'验证空集合状态'})},newAdminCookie);
expectStatus(reopenSecondForDraft.response.status,200,'组管理员重新打开第二件作品',reopenSecondForDraft.body);
const draftWork=await request(`/api/notes/${workId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'draft'})});
expectStatus(draftWork.response.status,200,'作品退回草稿',draftWork.body);
const draftSecondWork=await request(`/api/notes/${secondWorkId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'draft'})});
expectStatus(draftSecondWork.response.status,200,'新增作品退回草稿',draftSecondWork.body);
const draftCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
const draftCollection=(draftCollections.body as Array<{id:number;status:string;work_count:number;completed_at:string|null}>).find((item)=>Number(item.id)===collectionId);
if(draftCollection?.status!=='draft'||Number(draftCollection.work_count)!==0||draftCollection.completed_at!==null)throw new Error('无已提交作品时未回到待提交');
const resubmitWork=await request(`/api/notes/${workId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'pending'})});
expectStatus(resubmitWork.response.status,200,'重新提交作品',resubmitWork.body);
const deleteWork=await request(`/api/notes/${workId}`,{method:'DELETE',headers:bearerHeaders});
expectStatus(deleteWork.response.status,204,'删除最后一件作品',deleteWork.body);
const deleteSecondWork=await request(`/api/notes/${secondWorkId}`,{method:'DELETE',headers:bearerHeaders});
expectStatus(deleteSecondWork.response.status,204,'删除第二件作品',deleteSecondWork.body);
const emptyCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
const emptyCollection=(emptyCollections.body as Array<{id:number;status:string;work_count:number}>).find((item)=>Number(item.id)===collectionId);
if(emptyCollection?.status!=='draft'||Number(emptyCollection.work_count)!==0)throw new Error('删除最后一件作品后未回到待提交');
const revokeProjectKey=await request(`/api/management/api-keys/${projectKeyId}`,{method:'DELETE'},newAdminCookie);
expectStatus(revokeProjectKey.response.status,204,'吊销项目级 API Key');
const apiKey = await request('/api/management/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '运行时测试 Key' }) }, adminCookie);
expectStatus(apiKey.response.status, 201, '创建平台 API Key');
const keyId = Number((apiKey.body as { item: { id: number } }).item.id);
const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie);
expectStatus(revoke.response.status, 204, '吊销平台 API Key');
process.stdout.write('PostgreSQL 运行时验证通过:账号角色、项目隔离、客户门禁、作品交付集、API Key 发现、externalId 幂等、JSON URL 作品与新版本\n');
process.stdout.write('PostgreSQL 运行时验证通过:账号角色、项目隔离、客户门禁、项目自动状态、完成只读、API Key 发现、externalId 幂等、COS 同源复用、异源转存、SSRF 拦截与单方案轮次\n');
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await closeDatabase();

View File

@@ -0,0 +1,63 @@
process.env.NODE_ENV = 'test';
delete process.env.DATABASE_URL;
const { db } = await import('../api/db.js');
const { database, closeDatabase } = await import('../api/database.js');
const { deriveCollectionStatus, recalculateCollectionStatus } = await import('../api/services/collectionsService.js');
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
async function current(collectionId: number) {
return database.one<{ status: string; completed_at: string | null }>('SELECT status, completed_at FROM collections WHERE id = ?', [collectionId]);
}
assert(deriveCollectionStatus(0, 0) === 'draft', '空作品交付集应为待提交');
assert(deriveCollectionStatus(2, 1) === 'reviewing', '存在未通过作品时应为验收中');
assert(deriveCollectionStatus(2, 2) === 'completed', '全部作品通过后应为验收完毕');
db.exec('BEGIN IMMEDIATE');
try {
const project = await database.one<{ id: number }>('SELECT id FROM projects ORDER BY id LIMIT 1');
assert(project, 'SQLite 测试需要至少一个项目');
const collectionId = await database.insertId("INSERT INTO collections (project_id, name, status) VALUES (?, ?, 'draft')", [project.id, `状态测试-${Date.now()}`]);
await recalculateCollectionStatus(collectionId);
assert((await current(collectionId))?.status === 'draft', '新建空作品交付集状态错误');
const noteId = await database.insertId("INSERT INTO notes (collection_id, title, review_status) VALUES (?, ?, 'pending')", [collectionId, 'SQLite 状态测试作品']);
await recalculateCollectionStatus(collectionId);
assert((await current(collectionId))?.status === 'reviewing', '新增待验收作品后未进入验收中');
await database.execute("UPDATE notes SET review_status = 'approved' WHERE id = ?", [noteId]);
await recalculateCollectionStatus(collectionId);
const completed = await current(collectionId);
assert(completed?.status === 'completed' && completed.completed_at, '全部通过后未完成或缺少完成时间');
const secondNoteId = await database.insertId("INSERT INTO notes (collection_id, title, review_status) VALUES (?, ?, 'pending')", [collectionId, 'SQLite 状态测试作品二']);
const partial = await recalculateCollectionStatus(collectionId);
assert(partial?.status === 'reviewing' && partial.workCount === 2 && partial.approvedCount === 1, '部分作品通过时应保持验收中');
await database.execute("UPDATE notes SET review_status = 'approved' WHERE id = ?", [secondNoteId]);
await recalculateCollectionStatus(collectionId);
assert((await current(collectionId))?.status === 'completed', '多件作品全部通过后应验收完毕');
await database.execute("UPDATE collections SET status = 'archived' WHERE id = ?", [collectionId]);
await database.execute("UPDATE notes SET review_status = 'pending' WHERE id = ?", [noteId]);
await recalculateCollectionStatus(collectionId);
assert((await current(collectionId))?.status === 'archived', '自动重算不应覆盖手动归档状态');
await database.execute("UPDATE collections SET status = 'reviewing' WHERE id = ?", [collectionId]);
await recalculateCollectionStatus(collectionId);
assert((await current(collectionId))?.status === 'reviewing', '恢复归档后应按作品状态重算');
await database.execute("UPDATE notes SET review_status = 'draft' WHERE id IN (?, ?)", [noteId, secondNoteId]);
await recalculateCollectionStatus(collectionId);
const draft = await current(collectionId);
assert(draft?.status === 'draft' && draft.completed_at === null, '全部作品退回草稿后应回到待提交并清除完成时间');
process.stdout.write('SQLite 作品交付集状态验证通过:待提交、验收中、验收完毕、归档保护与恢复重算\n');
} finally {
db.exec('ROLLBACK');
await closeDatabase();
}

View File

@@ -0,0 +1,68 @@
process.env.NODE_ENV = 'test';
delete process.env.DATABASE_URL;
const { db, repairMultiSchemeRounds } = await import('../api/db.js');
const { database, closeDatabase } = await import('../api/database.js');
const { decideRoundInTransaction, ReviewDecisionError } = await import('../api/services/reviewService.js');
const { addFeedbackReply, findFeedbackTarget, withdrawFeedback } = await import('../api/services/feedbackService.js');
function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); }
db.exec('BEGIN IMMEDIATE');
try {
const group = await database.one<{ id: number }>('SELECT id FROM operation_groups ORDER BY id LIMIT 1');
assert(group, 'SQLite 测试需要至少一个运营组');
const project = { id: await database.insertId("INSERT INTO projects (group_id,name,slug) VALUES (?,?,?)", [group.id, '单轮验收测试', `round-test-${Date.now()}`]) };
const collectionId = await database.insertId("INSERT INTO collections (project_id,name,status) VALUES (?,?,'reviewing')", [project.id, `轮次测试 ${Date.now()}`]);
const noteId = await database.insertId("INSERT INTO notes (project_id,collection_id,title,review_status) VALUES (?,?,?,'pending')", [project.id, collectionId, '第 1 轮']);
const round1 = await database.insertId("INSERT INTO review_rounds (note_id,round_number,status) VALUES (?,1,'reviewing')", [noteId]);
await database.execute('UPDATE notes SET active_round_id=? WHERE id=?', [round1, noteId]);
await database.execute("INSERT INTO work_versions (note_id,version_number,title,review_status,review_round_id,candidate_name,candidate_status) VALUES (?,1,?,'pending',?,'','pending')", [noteId, '第 1 轮', round1]);
db.exec('DROP INDEX idx_work_versions_one_per_round');
await database.execute("INSERT INTO work_versions (note_id,version_number,title,review_status,review_round_id) VALUES (?,99,?,'pending',?)", [noteId, '旧多方案数据', round1]);
repairMultiSchemeRounds();
const migrated = await database.one<{ review_round_id: number; round_status: string }>('SELECT v.review_round_id,r.status AS round_status FROM work_versions v JOIN review_rounds r ON r.id=v.review_round_id WHERE v.note_id=? AND v.version_number=99', [noteId]);
assert(migrated && Number(migrated.review_round_id) !== Number(round1) && migrated.round_status === 'completed', '旧多方案轮次未拆分为单方案历史轮次');
await database.execute('DELETE FROM work_versions WHERE note_id=? AND version_number=99', [noteId]);
await database.execute('DELETE FROM review_rounds WHERE id=?', [migrated.review_round_id]);
db.exec('CREATE UNIQUE INDEX idx_work_versions_one_per_round ON work_versions(review_round_id) WHERE review_round_id IS NOT NULL');
let duplicateRejected = false;
try { await database.execute("INSERT INTO work_versions (note_id,version_number,title,review_status,review_round_id) VALUES (?,2,?,'pending',?)", [noteId, '非法第二方案', round1]); }
catch { duplicateRejected = true; }
assert(duplicateRejected, '同一验收轮次必须拒绝第二个方案');
await decideRoundInTransaction(database, { noteId, versionNumber: 1, projectId: project.id, decision: 'changes_requested', reason: '需要调整', actorName: '测试客户', actorRole: 'client' });
assert((await database.one<{ review_status: string }>('SELECT review_status FROM notes WHERE id=?', [noteId]))?.review_status === 'changes_requested', '要求修改后作品状态错误');
assert((await database.one<{ completion_reason: string }>('SELECT completion_reason FROM review_rounds WHERE id=?', [round1]))?.completion_reason === 'changes_requested', '退修轮次未完成');
const round2 = await database.insertId("INSERT INTO review_rounds (note_id,round_number,status) VALUES (?,2,'reviewing')", [noteId]);
await database.execute("INSERT INTO work_versions (note_id,version_number,title,review_status,review_round_id,candidate_name,candidate_status) VALUES (?,2,?,'pending',?,'','pending')", [noteId, '第 2 轮', round2]);
await database.execute("UPDATE notes SET active_round_id=?,version_number=2,title=?,review_status='pending' WHERE id=?", [round2, '第 2 轮', noteId]);
const imageId = await database.insertId("INSERT INTO images (note_id,url,version_number) VALUES (?, '/test.jpg', 2)", [noteId]);
const annotationId = await database.insertId("INSERT INTO annotations (image_id,x,y,content,author_name,author_role) VALUES (?,.5,.5,'待处理','测试运营','operator')", [imageId]);
const commentId = await database.insertId("INSERT INTO work_comments (note_id,version_number,content) VALUES (?,2,'总体意见')", [noteId]);
const feedbackTarget = await findFeedbackTarget(noteId, 'image_annotation', annotationId);
assert(feedbackTarget, '无法按作品找到图片批注');
await addFeedbackReply(feedbackTarget, 'image_annotation', annotationId, '已收到,正在处理', '测试客户', 'client');
assert((await database.one<{ count: number }>('SELECT COUNT(*) AS count FROM feedback_replies WHERE note_id=? AND feedback_id=?', [noteId, annotationId]))?.count === 1, '批注回复未保存');
await withdrawFeedback('image_annotation', annotationId);
assert(Boolean((await database.one<{ withdrawn_at: string | null }>('SELECT withdrawn_at FROM annotations WHERE id=?', [annotationId]))?.withdrawn_at), '撤回没有保留时间记录');
await decideRoundInTransaction(database, { noteId, versionNumber: 2, projectId: project.id, decision: 'approved', reason: '', actorName: '测试客户', actorRole: 'client' });
const note = await database.one<{ version_number: number; review_status: string }>('SELECT version_number,review_status FROM notes WHERE id=?', [noteId]);
assert(note?.version_number === 2 && note.review_status === 'approved', '第 2 轮通过后作品状态错误');
assert((await database.one<{ review_status: string }>('SELECT review_status FROM projects WHERE id=?', [project.id]))?.review_status === 'completed', '全部作品通过后项目未完成');
assert((await database.one<{ closure_reason: string }>('SELECT closure_reason FROM work_comments WHERE id=?', [commentId]))?.closure_reason === 'approved_with_round', '未处理反馈没有随轮关闭');
let historicalRejected = false;
try { await decideRoundInTransaction(database, { noteId, versionNumber: 1, projectId: project.id, decision: 'approved', reason: '', actorName: '测试客户', actorRole: 'client' }); }
catch (error) { historicalRejected = error instanceof ReviewDecisionError && error.statusCode === 409; }
assert(historicalRejected, '历史轮次不应被重复验收');
process.stdout.write('SQLite 单方案轮次验证通过:一轮一稿、退修、回复、留痕撤回、通过、项目完成与历史只读\n');
} finally {
db.exec('ROLLBACK');
await closeDatabase();
}

View File

@@ -5,7 +5,7 @@ import { newDb } from 'pg-mem';
const tables = [
'operation_groups', 'users', 'projects', 'collections', 'notes', 'images', 'annotations',
'work_comments', 'work_versions', 'review_events', 'sessions', 'customer_sessions',
'work_comments', 'feedback_replies', 'work_versions', 'review_rounds', 'review_events', 'sessions', 'customer_sessions',
'audit_logs', 'api_keys', 'storage_configs',
] as const;
const booleanColumns: Record<string, Set<string>> = {
@@ -19,9 +19,16 @@ const sqlite = new Database(path.resolve('data/app.db'), { readonly: true });
try {
await client.connect();
const schema = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8')
const schemaSource = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8');
if (!/-- SINGLE_SCHEME_REPAIR_START[\s\S]+?-- SINGLE_SCHEME_REPAIR_END/.test(schemaSource)) throw new Error('PostgreSQL schema 缺少旧多方案数据修复脚本');
const schema = schemaSource
.replace(/^BEGIN;|COMMIT;$/gm, '')
.replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '');
.replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '')
.replace(/-- COLLECTION_STATUS_REPAIR_START[\s\S]+?-- COLLECTION_STATUS_REPAIR_END/, '')
.replace(/-- REVIEW_ROUND_REPAIR_START[\s\S]+?-- REVIEW_ROUND_REPAIR_END/, '')
.replace(/-- PROJECT_REVIEW_STATUS_REPAIR_START[\s\S]+?-- PROJECT_REVIEW_STATUS_REPAIR_END/, '')
.replace(/-- TEXT_ANNOTATION_TARGET_REPAIR_START[\s\S]+?-- TEXT_ANNOTATION_TARGET_REPAIR_END/, '')
.replace(/-- SINGLE_SCHEME_REPAIR_START[\s\S]+?-- SINGLE_SCHEME_REPAIR_END/, '');
await client.query(schema);
for (const table of tables) {
const exists = sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table);
@@ -37,6 +44,10 @@ try {
}
const current = (await client.query(`SELECT n.version_number,n.review_status,i.storage_provider FROM notes n JOIN images i ON i.note_id=n.id AND i.version_number=n.version_number WHERE n.id=1 LIMIT 1`)).rows[0];
if (!current || Number(current.version_number) < 1) throw new Error('作品版本关系未正确迁移');
const invalidRoundLinks = Number((await client.query('SELECT COUNT(*)::int AS count FROM work_versions v LEFT JOIN review_rounds r ON r.id=v.review_round_id WHERE r.id IS NULL')).rows[0].count);
const invalidActiveRounds = Number((await client.query('SELECT COUNT(*)::int AS count FROM notes n LEFT JOIN review_rounds r ON r.id=n.active_round_id WHERE r.id IS NULL OR r.note_id!=n.id')).rows[0].count);
const invalidProjects = Number((await client.query('SELECT COUNT(*)::int AS count FROM notes n LEFT JOIN projects p ON p.id=n.project_id WHERE p.id IS NULL')).rows[0].count);
if (invalidRoundLinks || invalidActiveRounds || invalidProjects) throw new Error(`迁移关联无效rounds=${invalidRoundLinks}, notes=${invalidActiveRounds}, projects=${invalidProjects}`);
const activeStorage = Number((await client.query("SELECT COUNT(*)::int AS count FROM storage_configs WHERE status='active'")).rows[0].count);
if (activeStorage > 1) throw new Error('活动对象存储配置超过一个');
process.stdout.write(`PostgreSQL schema 与迁移映射验证通过:${tables.length} 张表,当前作品 V${current.version_number},存储=${current.storage_provider}\n`);

View File

@@ -1,5 +1,7 @@
export type ReviewStatus = 'draft' | 'pending' | 'changes_requested' | 'approved';
export type ReviewRoundStatus = 'draft' | 'reviewing' | 'completed';
export type CollectionStatus = 'draft' | 'reviewing' | 'completed' | 'archived';
export type ProjectReviewStatus = 'draft' | 'reviewing' | 'completed' | 'archived';
export type UserRole = 'platform_admin' | 'group_admin' | 'operator';
export interface CurrentUser {
@@ -94,11 +96,16 @@ export interface Project {
slug: string;
client_description: string;
status: 'active' | 'closed' | 'archived';
review_status: ProjectReviewStatus;
review_completed_at: string | null;
customer_access_enabled: boolean;
access_expires_at: string | null;
has_access_password: boolean;
collection_count: number;
work_count: number;
pending_count: number;
changes_requested_count: number;
approved_count: number;
created_at: string;
}
@@ -119,11 +126,13 @@ export interface WorkCollection {
status: CollectionStatus;
work_count: number;
approved_count: number;
completed_at: string | null;
created_at: string;
}
export interface Note {
id: number;
project_id: number;
collection_id: number;
external_id: string | null;
title: string;
@@ -131,6 +140,8 @@ export interface Note {
tags: string[];
review_status: ReviewStatus;
version_number: number;
active_round_id: number | null;
approved_version_number: number | null;
cover_image: string;
image_count: number;
annotation_count: number;
@@ -156,17 +167,23 @@ export interface Annotation {
y: number;
content: string;
author_name: string;
author_role: 'client' | 'operator';
status: 'open' | 'resolved' | 'confirmed';
closure_reason: string;
withdrawn_at: string | null;
created_at: string;
}
export interface WorkComment {
id: number;
note_id: number;
version_number: number;
content: string;
author_name: string;
author_role: 'client' | 'operator';
status: 'open' | 'resolved' | 'confirmed';
closure_reason: string;
withdrawn_at: string | null;
created_at: string;
}
@@ -174,10 +191,33 @@ export interface TextAnnotation {
id: number;
note_id: number;
version_number: number;
target: 'title' | 'description';
target: 'title' | 'description' | 'tags';
start_offset: number;
end_offset: number;
selected_text: string;
prefix_text: string;
suffix_text: string;
content: string;
author_name: string;
author_role: 'client' | 'operator';
status: 'open' | 'resolved' | 'confirmed';
closure_reason: string;
withdrawn_at: string | null;
created_at: string;
}
export type FeedbackType = 'image_annotation' | 'text_annotation' | 'comment';
export interface FeedbackReply {
id: number;
note_id: number;
version_number: number;
feedback_type: FeedbackType;
feedback_id: number;
content: string;
author_name: string;
author_role: 'client' | 'operator';
withdrawn_at: string | null;
created_at: string;
}
@@ -189,14 +229,18 @@ export interface NoteDetail extends Note {
images: ImageWithAnnotations[];
text_annotations: TextAnnotation[];
comments: WorkComment[];
project: Pick<Project, 'id' | 'name' | 'slug'>;
collection: Pick<WorkCollection, 'id' | 'name'>;
versions: WorkVersion[];
feedback_replies: FeedbackReply[];
project: Pick<Project, 'id' | 'name' | 'slug' | 'status' | 'review_status'>;
rounds: WorkRound[];
review_events: ReviewEvent[];
}
export interface WorkVersion {
export interface WorkRound {
version_number: number;
review_round_id: number;
round_number: number;
round_status: ReviewRoundStatus;
completion_reason: string;
title: string;
description: string;
tags: string[];
@@ -204,6 +248,17 @@ export interface WorkVersion {
created_at: string;
}
export interface ReviewRound {
id: number;
note_id: number;
round_number: number;
status: ReviewRoundStatus;
selected_version_number: number | null;
completed_at: string | null;
completion_reason: string;
created_at: string;
}
export interface ReviewEvent {
id: number;
version_number: number;
@@ -221,6 +276,20 @@ export interface CreateAnnotationRequest {
y: number;
content: string;
author_name?: string;
author_role?: 'client' | 'operator';
}
export interface WorkFeedbackBundle {
work_id: number;
rounds: Array<{
round_number: number;
version_number: number;
image_annotations: Array<Annotation & { image_id: number; image_url: string }>;
text_annotations: TextAnnotation[];
comments: WorkComment[];
feedback_replies: FeedbackReply[];
review_events: ReviewEvent[];
}>;
}
export interface NoteListQuery {

Binary file not shown.

View File

@@ -39,6 +39,7 @@ function AppContent() {
<Route path="/review/:slug/collections/:collectionId" element={<CustomerReviewPage />} />
<Route path="/review/:slug/works/:noteId" element={<CustomerReviewPage />} />
<Route path="/works/:noteId/new-version" element={<ProtectedRoute><NewVersionPage /></ProtectedRoute>} />
<Route path="/projects/:projectId/upload" element={<ProtectedRoute><UploadPage /></ProtectedRoute>} />
<Route path="/projects/:projectId/collections/:collectionId/upload" element={<ProtectedRoute><UploadPage /></ProtectedRoute>} />
<Route path="/upload" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -1,4 +1,4 @@
import type { Annotation, AuditLogEntry, CurrentUser, CustomerAccessState, ManagedApiKey, ManagedUser, Note, NoteDetail, NoteListQuery, OperationGroup, Project, ReviewStatus, StorageConfig, TextAnnotation, UserRole, WorkCollection, WorkComment } from '@shared/types';
import type { Annotation, AuditLogEntry, CurrentUser, CustomerAccessState, FeedbackReply, FeedbackType, ManagedApiKey, ManagedUser, Note, NoteDetail, NoteListQuery, OperationGroup, Project, ReviewStatus, StorageConfig, TextAnnotation, UserRole, WorkComment, WorkFeedbackBundle } from '@shared/types';
export class ApiError extends Error {
constructor(public status: number, message: string) { super(message); this.name = 'ApiError'; }
@@ -44,43 +44,47 @@ export const api = {
createProject: (data: { name: string; slug: string; client_description: string; groupId?: number }) => request<Project>('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateProject: (id: number, data: { name: string; client_description: string }) => request<Project>(`/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateCustomerAccess: (id: number, data: { enabled: boolean; password?: string; expires_at?: string | null }) => request<Project>(`/api/projects/${id}/customer-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
listCollections: (projectId: number) => request<WorkCollection[]>(`/api/projects/${projectId}/collections`),
createCollection: (projectId: number, data: { name: string; client_description: string }) => request<WorkCollection>(`/api/projects/${projectId}/collections`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateCollection: (projectId: number, id: number, data: { name: string; client_description: string }) => request<WorkCollection>(`/api/projects/${projectId}/collections/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
listNotes: (query: NoteListQuery = {}) => {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => value != null && params.set(key, String(value)));
return request<Note[]>(`/api/notes?${params}`);
},
getNote: (id: number, version?: number) => request<NoteDetail>(`/api/notes/${id}${version ? `?version=${version}` : ''}`),
createNote: (payload: { collectionId: number; title: string; description: string; tags: string[]; images: File[] }) => {
const form = new FormData();
form.append('collectionId', String(payload.collectionId));
form.append('title', payload.title);
form.append('description', payload.description);
form.append('tags', payload.tags.join(','));
payload.images.forEach((file) => form.append('images', file));
return request<Note>('/api/notes', { method: 'POST', body: form });
listProjectWorks: (projectId: number, query: NoteListQuery = {}) => {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => value != null && params.set(key, String(value)));
return request<Note[]>(`/api/projects/${projectId}/works?${params}`);
},
createWorkVersion: (noteId: number, payload: { title: string; description: string; tags: string[]; images: File[] }) => {
getWork: (id: number, round?: number) => request<NoteDetail>(`/api/works/${id}${round ? `?round=${round}` : ''}`),
getWorkFeedback: (id: number) => request<WorkFeedbackBundle>(`/api/works/${id}/annotations`),
createWork: (projectId: number, payload: { title: string; description: string; tags: string[]; images: File[] }) => {
const form = new FormData();
form.append('title', payload.title); form.append('description', payload.description); form.append('tags', payload.tags.join(','));
payload.images.forEach((file) => form.append('images', file));
return request<Note>(`/api/notes/${noteId}/versions`, { method: 'POST', body: form });
return request<Note>(`/api/projects/${projectId}/works`, { method: 'POST', body: form });
},
createRound: (workId: number, payload: { title: string; description: string; tags: string[]; images: File[] }) => {
const form = new FormData();
form.append('title', payload.title); form.append('description', payload.description); form.append('tags', payload.tags.join(','));
payload.images.forEach((file) => form.append('images', file));
return request<Note>(`/api/works/${workId}/rounds`, { method: 'POST', body: form });
},
setReviewStatus: (id: number, status: ReviewStatus) => request<{ success: true; status: ReviewStatus }>(`/api/notes/${id}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) }),
reopenWork: (id: number, reason: string) => request<{ success: true; status: ReviewStatus }>(`/api/notes/${id}/reopen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason }) }),
addAnnotation: (imageId: number, data: { x: number; y: number; content: string; author_name?: string }) => request<Annotation>(`/api/images/${imageId}/annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
addTextAnnotation: (noteId: number, data: { version_number: number; target: 'title' | 'description'; content: string }) => request<TextAnnotation>(`/api/notes/${noteId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
addTextSelectionAnnotation: (workId: number, data: { round_number: number; target: 'title' | 'description' | 'tags'; start_offset: number; end_offset: number; selected_text: string; content: string }) => request<TextAnnotation>(`/api/works/${workId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
replyToFeedback: (workId: number, type: FeedbackType, feedbackId: number, content: string) => request<FeedbackReply>(`/api/works/${workId}/feedback/${type}/${feedbackId}/replies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }),
withdrawFeedback: (workId: number, type: FeedbackType, feedbackId: number) => request<{ success: true }>(`/api/works/${workId}/feedback/${type}/${feedbackId}/withdraw`, { method: 'POST' }),
deleteAnnotation: (id: number) => request<void>(`/api/annotations/${id}`, { method: 'DELETE' }),
addComment: (noteId: number, data: { content: string; author_name: string; author_role?: 'client' | 'operator' }) => request<WorkComment>(`/api/notes/${noteId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
getCustomerAccess: (slug: string) => request<CustomerAccessState>(`/api/review/${slug}/access`),
customerLogin: (slug: string, data: { reviewer_name: string; password: string }) => request<{ success: true; reviewer_name: string }>(`/api/review/${slug}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
getCustomerProject: (slug: string) => request<{ project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status'>; collections: WorkCollection[]; reviewer_name: string }>(`/api/review/${slug}/project`),
getCustomerCollection: (slug: string, collectionId: number) => request<{ collection: WorkCollection; works: Note[] }>(`/api/review/${slug}/collections/${collectionId}/works`),
getCustomerWork: (slug: string, noteId: number, version?: number) => request<NoteDetail>(`/api/review/${slug}/works/${noteId}${version ? `?version=${version}` : ''}`),
getCustomerProject: (slug: string) => request<{ project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status' | 'review_status'>; works: Note[]; reviewer_name: string }>(`/api/review/${slug}/project`),
getCustomerWorkRound: (slug: string, workId: number, round?: number) => request<NoteDetail>(`/api/review/${slug}/works/${workId}${round ? `?round=${round}` : ''}`),
getCustomerWorkFeedback: (slug: string, workId: number) => request<WorkFeedbackBundle>(`/api/review/${slug}/works/${workId}/annotations`),
addCustomerComment: (slug: string, noteId: number, content: string) => request<WorkComment>(`/api/review/${slug}/works/${noteId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }),
addCustomerAnnotation: (slug: string, imageId: number, data: { x: number; y: number; content: string }) => request<Annotation>(`/api/review/${slug}/images/${imageId}/annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
addCustomerTextAnnotation: (slug: string, noteId: number, data: { version_number: number; target: 'title' | 'description'; content: string }) => request<TextAnnotation>(`/api/review/${slug}/works/${noteId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
submitCustomerDecision: (slug: string, noteId: number, decision: 'approved' | 'changes_requested', reason?: string) => request<{ success: true; status: ReviewStatus }>(`/api/review/${slug}/works/${noteId}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ decision, reason }) }),
addCustomerTextSelectionAnnotation: (slug: string, workId: number, data: { round_number: number; target: 'title' | 'description' | 'tags'; start_offset: number; end_offset: number; selected_text: string; content: string }) => request<TextAnnotation>(`/api/review/${slug}/works/${workId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
replyToCustomerFeedback: (slug: string, workId: number, type: FeedbackType, feedbackId: number, content: string) => request<FeedbackReply>(`/api/review/${slug}/works/${workId}/feedback/${type}/${feedbackId}/replies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }),
withdrawCustomerFeedback: (slug: string, workId: number, type: FeedbackType, feedbackId: number) => request<{ success: true }>(`/api/review/${slug}/works/${workId}/feedback/${type}/${feedbackId}/withdraw`, { method: 'POST' }),
submitCustomerRoundDecision: (slug: string, workId: number, roundNumber: number, decision: 'approved' | 'changes_requested', reason?: string) => request<{ success: true; status: ReviewStatus; version_number: number }>(`/api/review/${slug}/works/${workId}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ round_number: roundNumber, decision, reason }) }),
};

View File

@@ -1,13 +1,14 @@
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Check, MessageCircle, X } from 'lucide-react';
import type { Annotation, NoteImage } from '@shared/types';
export default function AnnotatableImage({image,annotations,onAdd}:{image:NoteImage;annotations:Annotation[];onAdd:(x:number,y:number,text:string)=>Promise<void>}){
export default function AnnotatableImage({image,annotations,onAdd,readOnly=false,onOpen,onAnnotationOpen,initialAnnotationId}:{image:NoteImage;annotations:Annotation[];onAdd:(x:number,y:number,text:string)=>Promise<void>;readOnly?:boolean;onOpen?:(annotationId?:number)=>void;onAnnotationOpen?:(annotationId:number)=>void;initialAnnotationId?:number}){
const ref=useRef<HTMLDivElement>(null);const [point,setPoint]=useState<{x:number;y:number}|null>(null);const [selectedId,setSelectedId]=useState<number|null>(null);const [text,setText]=useState('');
useEffect(()=>setSelectedId(initialAnnotationId??null),[initialAnnotationId,image.id]);
const selected=annotations.find(annotation=>annotation.id===selectedId);
const click=(e:React.MouseEvent)=>{if((e.target as HTMLElement).closest('button,textarea'))return;if(selectedId!==null){setSelectedId(null);return}const r=ref.current!.getBoundingClientRect();setPoint({x:(e.clientX-r.left)/r.width,y:(e.clientY-r.top)/r.height});setText('')};
const click=(e:React.MouseEvent)=>{if((e.target as HTMLElement).closest('button,textarea'))return;if(onOpen){onOpen();return}if(selectedId!==null){setSelectedId(null);return}if(readOnly)return;const r=ref.current!.getBoundingClientRect();setPoint({x:(e.clientX-r.left)/r.width,y:(e.clientY-r.top)/r.height});setText('')};
const submit=async()=>{if(!point||!text.trim())return;await onAdd(point.x,point.y,text.trim());setPoint(null);setText('')};
return <figure className="mb-5 break-inside-avoid"><div ref={ref} onClick={click} className="relative cursor-crosshair overflow-hidden rounded-2xl bg-[#e9e7e0]" style={{aspectRatio:image.width&&image.height?`${image.width}/${image.height}`:'4/3'}}><img src={image.url} className="h-full w-full object-contain" draggable={false}/>{annotations.map((annotation,index)=><button key={annotation.id} aria-label={`查看批注 ${index+1}`} onClick={event=>{event.stopPropagation();setPoint(null);setSelectedId(current=>current===annotation.id?null:annotation.id)}} className={`absolute z-10 grid h-7 w-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border-2 border-white text-[10px] font-bold text-white shadow-lg transition ${selectedId===annotation.id?'scale-110 bg-black':'bg-[#ef4b2f] hover:scale-110'}`} style={{left:`${annotation.x*100}%`,top:`${annotation.y*100}%`}}>{index+1}</button>)}
{selected&&<div className="absolute z-30 w-64 max-w-[calc(100%-1rem)] rounded-2xl border border-black/10 bg-white p-4 shadow-2xl" style={{left:`${Math.min(.76,Math.max(.24,selected.x))*100}%`,top:`${selected.y*100}%`,transform:selected.y>.62?'translate(-50%, calc(-100% - 18px))':'translate(-50%, 18px)'}} onClick={event=>event.stopPropagation()}><div className="flex items-start justify-between gap-3"><div><p className="font-mono text-[9px] uppercase tracking-[.18em] text-[#ef4b2f]"> {annotations.findIndex(annotation=>annotation.id===selected.id)+1}</p><p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-black/75">{selected.content}</p></div><button aria-label="关闭批注" onClick={()=>setSelectedId(null)} className="grid h-7 w-7 flex-none place-items-center rounded-full bg-black/5 text-black/45 transition hover:bg-black hover:text-white"><X size={13}/></button></div><div className="mt-3 border-t border-black/[.07] pt-3 text-[11px] text-black/40"> · <span className="font-medium text-black/65">{selected.author_name||'未知用户'}</span></div></div>}
{point&&<div className="absolute z-20 w-64 -translate-x-1/2 rounded-2xl bg-white p-3 shadow-2xl" style={{left:`${Math.min(.78,Math.max(.22,point.x))*100}%`,top:`${Math.min(.7,point.y)*100}%`}} onClick={event=>event.stopPropagation()}><div className="mb-2 flex items-center justify-between text-xs font-medium"><span className="flex items-center gap-1"><MessageCircle size={13}/></span><button aria-label="取消添加批注" onClick={()=>setPoint(null)}><X size={14}/></button></div><textarea autoFocus rows={3} value={text} onChange={event=>setText(event.target.value)} className="w-full resize-none rounded-lg border border-black/10 p-2 text-xs" placeholder="描述需要调整的位置…"/><button onClick={submit} className="mt-2 flex w-full items-center justify-center gap-1 rounded-full bg-black py-2 text-xs text-white"><Check size={12}/></button></div>}</div></figure>
return <figure className="mb-5 break-inside-avoid"><div ref={ref} onClick={click} className={`relative overflow-hidden rounded-2xl bg-[#e9e7e0] ${onOpen?'cursor-zoom-in':readOnly?'cursor-default':'cursor-crosshair'}`} style={{aspectRatio:image.width&&image.height?`${image.width}/${image.height}`:'4/3'}}><img src={image.url} className="h-full w-full object-contain" draggable={false}/>{annotations.map((annotation,index)=><button key={annotation.id} aria-label={`查看批注 ${index+1}`} onClick={event=>{event.stopPropagation();setPoint(null);if(onAnnotationOpen){onAnnotationOpen(annotation.id);return}if(onOpen){onOpen(annotation.id);return}setSelectedId(current=>current===annotation.id?null:annotation.id)}} className={`after:content-[''] absolute z-10 grid h-[22px] w-[22px] -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-white text-[9px] font-bold text-white shadow-lg transition after:absolute after:-inset-2 after:rounded-full ${annotation.withdrawn_at?'bg-black/35':selectedId===annotation.id?'scale-110 bg-black':'bg-[#ef4b2f] hover:scale-110'}`} style={{left:`${annotation.x*100}%`,top:`${annotation.y*100}%`}}>{index+1}</button>)}
{selected&&<div className="absolute z-30 w-64 max-w-[calc(100%-1rem)] rounded-2xl border border-black/10 bg-white p-4 text-black shadow-2xl" style={{left:`${Math.min(.76,Math.max(.24,selected.x))*100}%`,top:`${selected.y*100}%`,transform:selected.y>.62?'translate(-50%, calc(-100% - 18px))':'translate(-50%, 18px)'}} onClick={event=>event.stopPropagation()}><div className="flex items-start justify-between gap-3"><div><p className="font-mono text-[9px] uppercase tracking-[.18em] text-[#ef4b2f]"> {annotations.findIndex(annotation=>annotation.id===selected.id)+1}</p><p className={`mt-2 whitespace-pre-wrap text-sm leading-6 ${selected.withdrawn_at?'italic text-black/35':'text-black/75'}`}>{selected.withdrawn_at?'该批注已撤回(记录保留)':selected.content}</p></div><button aria-label="关闭批注" onClick={()=>setSelectedId(null)} className="grid h-7 w-7 flex-none place-items-center rounded-full bg-black/5 text-black/45 transition hover:bg-black hover:text-white"><X size={13}/></button></div><div className="mt-3 border-t border-black/[.07] pt-3 text-[11px] text-black/40"> · <span className="font-medium text-black/65">{selected.author_name||'未知用户'}</span></div></div>}
{point&&<div className="absolute z-20 w-64 -translate-x-1/2 rounded-2xl bg-white p-3 text-black shadow-2xl" style={{left:`${Math.min(.78,Math.max(.22,point.x))*100}%`,top:`${Math.min(.7,point.y)*100}%`}} onClick={event=>event.stopPropagation()}><div className="mb-2 flex items-center justify-between text-xs font-medium"><span className="flex items-center gap-1"><MessageCircle size={13}/></span><button aria-label="取消添加批注" onClick={()=>setPoint(null)}><X size={14}/></button></div><textarea autoFocus rows={3} value={text} onChange={event=>setText(event.target.value)} className="w-full resize-none rounded-lg border border-black/10 p-2 text-xs text-black" placeholder="描述需要调整的位置…"/><button onClick={submit} className="mt-2 flex w-full items-center justify-center gap-1 rounded-full bg-black py-2 text-xs text-white"><Check size={12}/></button></div>}</div></figure>
}

View File

@@ -1,28 +1,44 @@
import { useState } from 'react';
import { Check, MessageCircle, Plus, X } from 'lucide-react';
import { useMemo, useRef } from 'react';
import type { TextAnnotation } from '@shared/types';
export default function AnnotatableText({ label, annotations, onAdd, children }: { label: string; annotations: TextAnnotation[]; onAdd: (content: string) => Promise<void>; children: React.ReactNode }) {
const [adding, setAdding] = useState(false);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
const selected = annotations.find((annotation) => annotation.id === selectedId);
export type TextSelectionDraft = { start: number; end: number; text: string };
const submit = async () => {
if (!text.trim() || busy) return;
setBusy(true);
try { await onAdd(text.trim()); setText(''); setAdding(false); }
finally { setBusy(false); }
export default function AnnotatableText({ text, annotations, onSelect, onOpenAnnotation, readOnly = false, variant = 'body' }: {
text: string;
label: string;
annotations: TextAnnotation[];
onSelect: (selection: TextSelectionDraft) => void;
onOpenAnnotation: (annotationId: number) => void;
readOnly?: boolean;
variant?: 'title' | 'body';
}) {
const root = useRef<HTMLElement>(null);
const visibleAnnotations = useMemo(() => annotations.filter((item) => !item.withdrawn_at), [annotations]);
const boundaries = useMemo(() => Array.from(new Set([0, text.length, ...visibleAnnotations.flatMap((item) => [item.start_offset, item.end_offset])])).filter((value) => value >= 0 && value <= text.length).sort((a, b) => a - b), [visibleAnnotations, text.length]);
const captureSelection = () => {
if (readOnly || !root.current) return;
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.rangeCount) return;
const range = selection.getRangeAt(0);
if (!root.current.contains(range.commonAncestorContainer)) return;
const before = range.cloneRange();
before.selectNodeContents(root.current);
before.setEnd(range.startContainer, range.startOffset);
const selectedText = range.toString();
const start = before.toString().length;
const end = start + selectedText.length;
if (!selectedText.trim() || text.slice(start, end) !== selectedText) return;
onSelect({ start, end, text: selectedText });
selection.removeAllRanges();
};
return <div className="relative">
{children}
<div className="mt-3 flex flex-wrap items-center gap-2">
<button type="button" onClick={() => { setAdding(true); setSelectedId(null); }} className="inline-flex items-center gap-1.5 rounded-full border border-black/10 bg-white px-3 py-1.5 text-[11px] text-black/45 transition hover:border-black/25 hover:text-black"><Plus size={11}/>{label}</button>
{annotations.map((annotation, index) => <button key={annotation.id} type="button" aria-label={`查看${label}批注 ${index + 1}`} onClick={() => { setAdding(false); setSelectedId((current) => current === annotation.id ? null : annotation.id); }} className={`grid h-7 min-w-7 place-items-center rounded-full px-2 text-[10px] font-semibold text-white transition ${selectedId === annotation.id ? 'bg-black' : 'bg-[#ef4b2f] hover:scale-105'}`}>{index + 1}</button>)}
</div>
{selected && <div className="mt-3 max-w-md rounded-2xl border border-black/10 bg-white p-4 shadow-lg"><div className="flex items-start justify-between gap-3"><div><p className="font-mono text-[9px] uppercase tracking-[.18em] text-[#ef4b2f]">{label} {annotations.findIndex((annotation) => annotation.id === selected.id) + 1}</p><p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-black/75">{selected.content}</p></div><button aria-label="关闭批注" onClick={() => setSelectedId(null)} className="grid h-7 w-7 flex-none place-items-center rounded-full bg-black/5 text-black/45 hover:bg-black hover:text-white"><X size={13}/></button></div><div className="mt-3 border-t border-black/[.07] pt-3 text-[11px] text-black/40"> · <span className="font-medium text-black/65">{selected.author_name || '未知用户'}</span></div></div>}
{adding && <div className="mt-3 max-w-md rounded-2xl border border-black/10 bg-white p-4 shadow-lg"><div className="mb-3 flex items-center justify-between text-xs font-medium"><span className="flex items-center gap-1.5"><MessageCircle size={13}/>{label}</span><button aria-label="取消添加批注" onClick={() => setAdding(false)}><X size={14}/></button></div><textarea autoFocus rows={3} value={text} onChange={(event) => setText(event.target.value)} className="w-full resize-none rounded-xl border border-black/10 p-3 text-sm outline-none focus:border-[#ef4b2f]" placeholder={`填写针对${label}的修改意见…`}/><button disabled={busy || !text.trim()} onClick={() => void submit()} className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-full bg-black py-2.5 text-xs text-white disabled:opacity-30"><Check size={12}/>{busy ? '正在提交…' : '提交批注'}</button></div>}
</div>;
const segments = boundaries.slice(0, -1).map((start, index) => {
const end = boundaries[index + 1];
const matches = visibleAnnotations.filter((item) => item.start_offset <= start && item.end_offset >= end);
return { start, value: text.slice(start, end), matches };
});
const rendered = <>{segments.map((segment) => segment.matches.length ? <mark key={segment.start} title={segment.matches.length > 1 ? `此处有 ${segment.matches.length} 条批注` : '查看批注'} onClick={() => onOpenAnnotation(segment.matches[0].id)} className="cursor-pointer rounded-[3px] bg-[#f2cb78]/45 px-[1px] text-inherit decoration-[#cf6b42] underline decoration-[1.5px] underline-offset-4 transition hover:bg-[#f2cb78]/75">{segment.value}</mark> : <span key={segment.start}>{segment.value}</span>)}</>;
return <div onClick={(event) => event.stopPropagation()}>{variant === 'title' ? <h1 ref={root as React.RefObject<HTMLHeadingElement>} onMouseUp={captureSelection} onTouchEnd={captureSelection} className="max-w-4xl whitespace-pre-wrap font-display text-3xl leading-[1.12] tracking-[-.035em] md:text-4xl">{rendered}</h1> : <p ref={root as React.RefObject<HTMLParagraphElement>} onMouseUp={captureSelection} onTouchEnd={captureSelection} className="max-w-3xl whitespace-pre-wrap text-[15px] leading-8 text-black/65">{rendered}</p>}</div>;
}

View File

@@ -0,0 +1,77 @@
import { useEffect, useState } from 'react';
import { Check, History, MessageSquareText, Reply, Send, Undo2, X } from 'lucide-react';
import type { FeedbackReply, FeedbackType, NoteDetail } from '@shared/types';
import type { TextSelectionDraft } from './AnnotatableText';
export type DrawerTextDraft = TextSelectionDraft & { label: string };
type Props = {
work: NoteDetail;
open: boolean;
setOpen: (value: boolean) => void;
readOnly: boolean;
actorName?: string;
actorRole?: 'client' | 'operator';
onComment?: (content: string) => Promise<void>;
onTextAnnotation?: (content: string) => Promise<void>;
onCancelTextAnnotation?: () => void;
onReply?: (type: FeedbackType, feedbackId: number, content: string) => Promise<void>;
onWithdraw?: (type: FeedbackType, feedbackId: number) => Promise<void>;
footer?: React.ReactNode;
focusImageId?: number;
focusFeedback?: { type: FeedbackType; id: number } | null;
textDraft?: DrawerTextDraft | null;
};
export default function CollaborationDrawer({ work, open, setOpen, readOnly, actorName, actorRole, onComment, onTextAnnotation, onCancelTextAnnotation, onReply, onWithdraw, footer, focusImageId, focusFeedback, textDraft }: Props) {
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const imageAnnotations = work.images.filter((image) => !focusImageId || image.id === focusImageId).flatMap((image) => image.annotations.map((annotation) => ({ ...annotation, imageId: image.id })));
const feedbackCount = imageAnnotations.length + work.text_annotations.length + work.comments.length;
const textAnnotationGroups = [
{ target: 'title', label: '标题', items: work.text_annotations.filter((item) => item.target === 'title') },
{ target: 'description', label: '正文', items: work.text_annotations.filter((item) => item.target === 'description') },
{ target: 'tags', label: 'Tag', items: work.text_annotations.filter((item) => item.target === 'tags') },
].filter((group) => group.items.length > 0);
const submit = async () => { if (!onComment || !comment.trim()) return; setBusy(true); try { await onComment(comment.trim()); setComment(''); } finally { setBusy(false); } };
const repliesFor = (type: FeedbackType, id: number) => (work.feedback_replies ?? []).filter((reply) => reply.feedback_type === type && Number(reply.feedback_id) === Number(id));
useEffect(() => {
if (!open || !focusFeedback) return;
const frame = window.requestAnimationFrame(() => document.getElementById(`feedback-${focusFeedback.type}-${focusFeedback.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }));
return () => window.cancelAnimationFrame(frame);
}, [focusFeedback, open]);
return <>
{!open && <button onClick={(event) => { event.stopPropagation(); setOpen(true); }} className="fixed bottom-5 right-5 z-50 flex items-center gap-2 rounded-full bg-[#171714] px-5 py-3.5 text-sm text-white shadow-2xl shadow-black/25 transition hover:-translate-y-0.5"><MessageSquareText size={17}/><span></span>{feedbackCount > 0 && <b className="grid h-5 min-w-5 place-items-center rounded-full bg-[#ef4b2f] px-1.5 text-[10px]">{feedbackCount}</b>}</button>}
{open && <>
<button aria-label="关闭协作面板" onClick={() => setOpen(false)} className="fixed inset-0 z-[99] bg-black/30 md:hidden"/>
<aside onClick={(event) => event.stopPropagation()} className="fixed inset-x-0 bottom-0 z-[100] flex max-h-[88vh] flex-col rounded-t-[30px] border-black/10 bg-[#efede7] shadow-2xl md:inset-y-0 md:left-auto md:w-[420px] md:max-h-none md:rounded-none md:border-l">
<header className="flex items-start justify-between border-b border-black/10 p-5"><div><p className="font-mono text-[9px] uppercase tracking-[.24em] text-[#ba623c]">Review collaboration</p><h2 className="mt-2 font-display text-3xl"></h2><p className="mt-1 text-[11px] text-black/40">{focusImageId ? '当前图片反馈' : `${work.rounds.find((round) => round.version_number === work.version_number)?.round_number ?? '-'} 轮全部反馈`}</p></div><button aria-label="关闭协作面板" onClick={() => setOpen(false)} className="grid h-9 w-9 place-items-center rounded-full bg-white transition hover:bg-black hover:text-white"><X size={15}/></button></header>
<div className="flex-1 space-y-5 overflow-auto p-4">
{textDraft && onTextAnnotation && <TextAnnotationComposer key={`${textDraft.label}-${textDraft.start}-${textDraft.end}-${textDraft.text}`} draft={textDraft} onSubmit={onTextAnnotation} onCancel={onCancelTextAnnotation}/>}
<Section title="图片批注" count={imageAnnotations.length}>{imageAnnotations.map((item, index) => <FeedbackCard key={item.id} type="image_annotation" id={item.id} focused={focusFeedback?.type === 'image_annotation' && focusFeedback.id === item.id} name={item.author_name} role={item.author_role} meta={`图片 ${work.images.findIndex((image) => image.id === item.imageId) + 1} · 标记 ${index + 1}`} content={item.content} withdrawn={Boolean(item.withdrawn_at)} replies={repliesFor('image_annotation', item.id)} {...{readOnly,actorName,actorRole,onReply,onWithdraw}}/>)}</Section>
<Section title="文字批注" count={work.text_annotations.length}>{textAnnotationGroups.map((group) => <div key={group.target} className="rounded-2xl border border-black/[.06] bg-black/[.025] p-2"><div className="mb-2 flex items-center justify-between px-1"><span className="inline-flex items-center gap-2 text-[11px] font-semibold text-black/65"><i className="h-2 w-2 rounded-full bg-[#ba623c]"/>{group.label}</span><span className="rounded-full bg-white px-2 py-0.5 text-[9px] text-black/40">{group.items.length} </span></div><div className="space-y-2">{group.items.map((item, index) => <FeedbackCard key={item.id} type="text_annotation" id={item.id} focused={focusFeedback?.type === 'text_annotation' && focusFeedback.id === item.id} name={item.author_name} role={item.author_role} meta={`批注 ${index + 1}`} context={item.selected_text} content={item.content} withdrawn={Boolean(item.withdrawn_at)} replies={repliesFor('text_annotation', item.id)} {...{readOnly,actorName,actorRole,onReply,onWithdraw}}/>)}</div></div>)}</Section>
<Section title="总体反馈" count={work.comments.length}>{work.comments.map((item) => <FeedbackCard key={item.id} type="comment" id={item.id} focused={focusFeedback?.type === 'comment' && focusFeedback.id === item.id} name={item.author_name} role={item.author_role} meta={item.author_role === 'client' ? '客户' : '工作台'} content={item.content} withdrawn={Boolean(item.withdrawn_at)} replies={repliesFor('comment', item.id)} {...{readOnly,actorName,actorRole,onReply,onWithdraw}}/>)}</Section>
{work.review_events.length > 0 && <Section title="验收记录" count={work.review_events.length}>{work.review_events.map((event) => <div key={event.id} className="rounded-xl bg-white/65 p-3 text-[11px] leading-5 text-black/55"><History className="mr-2 inline" size={12}/> {work.rounds.find((round) => round.version_number === event.version_number)?.round_number ?? event.version_number} · {event.actor_name} · {event.to_status}{event.reason && <p className="mt-1 text-black/70">{event.reason}</p>}</div>)}</Section>}
</div>
<footer className="border-t border-black/10 bg-[#f6f4ef] p-4">{!readOnly && onComment && <div className="relative"><textarea rows={3} value={comment} onChange={(event) => setComment(event.target.value)} className="w-full resize-none rounded-2xl border border-black/10 bg-white p-3 pr-12 text-sm outline-none focus:border-[#ba623c]" placeholder="针对整个作品留下意见…"/><button aria-label="提交总体反馈" disabled={busy || !comment.trim()} onClick={() => void submit()} className="absolute bottom-3 right-3 rounded-full bg-black p-2 text-white disabled:opacity-30"><Send size={14}/></button></div>}{readOnly && <p className="text-center text-xs text-black/40"></p>}{footer}</footer>
</aside>
</>}
</>;
}
function TextAnnotationComposer({ draft, onSubmit, onCancel }: { draft: DrawerTextDraft; onSubmit: (content: string) => Promise<void>; onCancel?: () => void }) {
const [content, setContent] = useState(''); const [busy, setBusy] = useState(false);
const submit = async () => { if (!content.trim() || busy) return; setBusy(true); try { await onSubmit(content.trim()); setContent(''); } finally { setBusy(false); } };
return <section className="rounded-2xl border border-[#ba623c]/20 bg-white p-4 shadow-lg shadow-[#8b5530]/5"><div className="flex items-start justify-between gap-4"><div><p className="text-[10px] font-medium uppercase tracking-[.16em] text-[#ba623c]"></p><p className="mt-2 line-clamp-3 rounded-lg bg-[#f7f1e6] px-3 py-2 text-xs leading-5 text-black/60">{draft.text}</p></div>{onCancel && <button aria-label="取消文字批注" onClick={onCancel} className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-black/5"><X size={12}/></button>}</div><textarea autoFocus rows={4} value={content} onChange={(event) => setContent(event.target.value)} className="mt-3 w-full resize-none rounded-xl border border-black/10 p-3 text-sm outline-none focus:border-[#ba623c]" placeholder="填写针对这段文字的意见…"/><button disabled={busy || !content.trim()} onClick={() => void submit()} className="mt-2 flex w-full items-center justify-center gap-2 rounded-full bg-black py-2.5 text-xs text-white disabled:opacity-30"><Check size={12}/>{busy ? '正在提交…' : '提交批注'}</button></section>;
}
function Section({ title, count, children }: { title: string; count: number; children: React.ReactNode }) { return <section><div className="mb-2 flex items-center justify-between text-xs text-black/45"><b>{title}</b><span>{count}</span></div><div className="space-y-2">{count ? children : <p className="rounded-xl border border-dashed border-black/10 py-5 text-center text-[11px] text-black/30"></p>}</div></section>; }
function FeedbackCard({ type, id, name, role, meta, context, content, withdrawn, replies, readOnly, actorName, actorRole, onReply, onWithdraw, focused }: { type: FeedbackType; id: number; name: string; role: 'client'|'operator'; meta: string; context?: string; content: string; withdrawn: boolean; replies: FeedbackReply[]; readOnly: boolean; actorName?: string; actorRole?: 'client'|'operator'; onReply?: Props['onReply']; onWithdraw?: Props['onWithdraw']; focused: boolean }) {
const [replying, setReplying] = useState(false); const [text, setText] = useState(''); const [busy, setBusy] = useState(false);
const own = actorName === name && actorRole === role;
const submit = async () => { if (!onReply || !text.trim()) return; setBusy(true); try { await onReply(type, id, text.trim()); setText(''); setReplying(false); } finally { setBusy(false); } };
return <div id={`feedback-${type}-${id}`} className={`rounded-2xl bg-white p-3 transition duration-300 ${focused ? 'ring-2 ring-[#d68a50] shadow-lg shadow-[#b96b32]/10' : ''}`}>{context && <p className="rounded-lg border-l-2 border-[#d68a50] bg-[#f8f3e9] px-3 py-2 text-xs leading-5 text-black/60">{context}</p>}<div className={`flex items-center justify-between gap-3 text-[10px] text-black/40 ${context ? 'mt-2' : ''}`}><b className="text-black/60">{name}</b><span>{meta}</span></div><p className={`mt-2 whitespace-pre-wrap text-sm leading-6 ${withdrawn ? 'italic text-black/30' : 'text-black/75'}`}>{withdrawn ? '该反馈已撤回(记录保留)' : content}</p>{replies.map((reply) => <div key={reply.id} className="ml-3 mt-2 border-l-2 border-[#e9c58a] pl-3 text-xs leading-5"><b className="text-black/50">{reply.author_name}</b><p className="text-black/65">{reply.withdrawn_at ? '该回复已撤回' : reply.content}</p></div>)}{!readOnly && !withdrawn && <div className="mt-3 flex gap-3 text-[10px] text-black/40"><button onClick={() => setReplying((value) => !value)} className="inline-flex items-center gap-1 hover:text-black"><Reply size={11}/></button>{own && onWithdraw && <button onClick={() => void onWithdraw(type, id)} className="inline-flex items-center gap-1 hover:text-[#ba623c]"><Undo2 size={11}/></button>}</div>}{replying && <div className="mt-3 flex gap-2"><input autoFocus value={text} onChange={(event) => setText(event.target.value)} className="min-w-0 flex-1 rounded-xl border border-black/10 px-3 py-2 text-xs" placeholder="回复这条反馈"/><button aria-label="提交回复" disabled={busy || !text.trim()} onClick={() => void submit()} className="rounded-full bg-black p-2 text-white disabled:opacity-30"><Send size={12}/></button></div>}</div>;
}

View File

@@ -0,0 +1,19 @@
import type { CollectionStatus } from '@shared/types';
const labels: Record<CollectionStatus, string> = {
draft: '待提交',
reviewing: '验收中',
completed: '验收完毕',
archived: '已归档',
};
const styles: Record<CollectionStatus, string> = {
draft: 'bg-black/5 text-black/50',
reviewing: 'bg-amber-50 text-amber-700',
completed: 'bg-emerald-50 text-emerald-700',
archived: 'bg-slate-100 text-slate-500',
};
export default function CollectionStatusBadge({ status }: { status: CollectionStatus }) {
return <span className={`rounded-full px-2.5 py-1 text-[10px] font-medium ${styles[status]}`}>{labels[status]}</span>;
}

View File

@@ -0,0 +1,62 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, Minus, Plus, RotateCcw, X } from 'lucide-react';
import type { NoteDetail } from '@shared/types';
import { api } from '@/api/client';
import AnnotatableImage from '@/components/AnnotatableImage';
type Props = {
work: NoteDetail;
initialImageId: number;
readOnly: boolean;
slug?: string;
onClose: () => void;
onReload: () => Promise<void>;
onImageChange: (imageId: number) => void;
onOpenAnnotation: (imageId: number, annotationId: number) => void;
};
export default function ImageReviewModal({ work, initialImageId, readOnly, slug, onClose, onReload, onImageChange, onOpenAnnotation }: Props) {
const initialIndex = Math.max(0, work.images.findIndex((image) => image.id === initialImageId));
const [index, setIndex] = useState(initialIndex);
const [zoom, setZoom] = useState(1);
const viewport = useRef<HTMLDivElement>(null);
const image = work.images[index];
useEffect(() => {
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
window.addEventListener('keydown', closeOnEscape);
return () => { document.body.style.overflow = previous; window.removeEventListener('keydown', closeOnEscape); };
}, [onClose]);
const go = (next: number) => {
if (!work.images[next]) return;
setIndex(next);
onImageChange(work.images[next].id);
setZoom(1);
viewport.current?.scrollTo({ left: 0, top: 0 });
};
const add = async (x: number, y: number, content: string) => {
if (slug) await api.addCustomerAnnotation(slug, image.id, { x, y, content });
else await api.addAnnotation(image.id, { x, y, content });
await onReload();
};
if (!image) return null;
return <div onClick={onClose} className="fixed inset-0 z-[90] grid place-items-center bg-black/70 p-2 backdrop-blur-md sm:p-5" role="dialog" aria-modal="true" aria-label="图片查看与批注">
<section onClick={(event) => event.stopPropagation()} className="flex h-[calc(100vh-1rem)] w-full max-w-[1500px] flex-col overflow-hidden rounded-[24px] border border-white/10 bg-[#171714] text-white shadow-2xl sm:h-[calc(100vh-2.5rem)] sm:rounded-[32px]">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-white/10 px-4 py-3 lg:px-6">
<div className="flex items-center gap-3"><button aria-label="关闭图片窗格" onClick={onClose} className="grid h-9 w-9 place-items-center rounded-full border border-white/15 text-white/70 transition hover:bg-white hover:text-black"><X size={16}/></button><div><p className="font-mono text-[9px] uppercase tracking-[.22em] text-[#f3bd69]">Image review</p><p className="mt-1 text-xs text-white/50"> {index + 1} / {work.images.length}</p></div></div>
<div className="flex flex-wrap items-center justify-end gap-2"><button onClick={() => setZoom((value) => Math.max(.5, value - .25))} aria-label="缩小" className="grid h-9 w-9 place-items-center rounded-full border border-white/15"><Minus size={14}/></button><button onClick={() => setZoom((value) => Math.min(3, value + .25))} aria-label="放大" className="grid h-9 w-9 place-items-center rounded-full border border-white/15"><Plus size={14}/></button><button onClick={() => setZoom(1)} aria-label="重置缩放" className="grid h-9 w-9 place-items-center rounded-full border border-white/15"><RotateCcw size={14}/></button></div>
</header>
<div className="flex min-h-0 min-w-0 flex-1 flex-col lg:flex-row">
<div className="relative min-h-0 min-w-0 flex-1">
<div ref={viewport} className="h-full min-w-0 overflow-auto p-3 lg:p-6"><div className="mx-auto transition-[width] duration-200" style={{ width: image.width ? `${image.width * zoom}px` : `${1100 * zoom}px` }}><AnnotatableImage key={image.id} image={image} annotations={image.annotations} readOnly={readOnly} onAdd={add} onAnnotationOpen={(annotationId) => onOpenAnnotation(image.id, annotationId)}/></div></div>
{index > 0 && <button onClick={() => go(index - 1)} aria-label="上一张图片" className="absolute left-3 top-1/2 z-40 grid h-12 w-12 -translate-y-1/2 place-items-center rounded-full border border-white/15 bg-black/70 text-white shadow-xl backdrop-blur transition hover:bg-white hover:text-black lg:left-6"><ChevronLeft size={22}/></button>}
{index < work.images.length - 1 && <button onClick={() => go(index + 1)} aria-label="下一张图片" className="absolute right-3 top-1/2 z-40 grid h-12 w-12 -translate-y-1/2 place-items-center rounded-full border border-white/15 bg-black/70 text-white shadow-xl backdrop-blur transition hover:bg-white hover:text-black lg:right-6"><ChevronRight size={22}/></button>}
</div>
</div>
</section>
</div>;
}

View File

@@ -1,21 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowLeft, ImageIcon, MessageCircle, Pencil, Plus, Search, X } from 'lucide-react';
import type { Note, Project, ReviewStatus, WorkCollection } from '@shared/types';
import { api } from '@/api/client';
import StatusBadge from '@/components/StatusBadge';
import { useAuthStore } from '@/store/useAuthStore';
import { Navigate, useParams } from 'react-router-dom';
export default function CollectionPage(){
const {projectId,collectionId}=useParams(); const pid=Number(projectId),cid=Number(collectionId); const [project,setProject]=useState<Project|null>(null); const [collection,setCollection]=useState<WorkCollection|null>(null); const [works,setWorks]=useState<Note[]>([]); const [q,setQ]=useState(''); const [status,setStatus]=useState<ReviewStatus|''>('');
const [editing,setEditing]=useState(false); const [editName,setEditName]=useState(''); const [editDesc,setEditDesc]=useState('');
const user=useAuthStore(state=>state.user);
useEffect(()=>{Promise.all([api.getProject(pid),api.listCollections(pid),api.listNotes({collectionId:cid})]).then(([p,cs,w])=>{setProject(p);setCollection(cs.find(x=>x.id===cid)||null);setWorks(w)})},[pid,cid]);
const filtered=useMemo(()=>works.filter(w=>(!q||w.title.toLowerCase().includes(q.toLowerCase()))&&(!status||w.review_status===status)),[works,q,status]); if(!project||!collection)return null;
return <main className="mx-auto max-w-[1500px] px-5 py-8 lg:px-10 lg:py-12"><Link to={`/projects/${pid}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/>{project.name}</Link>
<section className="mt-8 flex flex-col gap-7 border-b border-black/10 pb-9 md:flex-row md:items-end md:justify-between"><div><p className="font-mono text-[10px] uppercase tracking-[.28em] text-[#ef4b2f]">Collection / Review Board</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em] md:text-6xl">{collection.name}</h1><p className="mt-3 text-sm text-black/50">{collection.client_description}</p></div>{user&&<div className="flex flex-col gap-2 sm:flex-row"><button onClick={()=>{setEditName(collection.name);setEditDesc(collection.client_description);setEditing(true)}} className="inline-flex items-center justify-center gap-2 rounded-full border border-black/15 bg-white px-5 py-3 text-sm"><Pencil size={14}/></button><Link to={`/projects/${pid}/collections/${cid}/upload`} className="inline-flex items-center justify-center gap-2 rounded-full bg-black px-6 py-3 text-sm text-white"><Plus size={16}/> </Link></div>}</section>
<section className="sticky top-[66px] z-30 -mx-5 mt-0 flex flex-col gap-3 border-b border-black/10 bg-[#f7f6f2]/90 px-5 py-4 backdrop-blur md:flex-row md:items-center md:justify-between lg:-mx-10 lg:px-10"><div className="flex gap-2 overflow-auto">{([['','全部'],['pending','待验收'],['changes_requested','需修改'],['approved','已通过']] as [ReviewStatus|'',string][]).map(([v,l])=><button key={l} onClick={()=>setStatus(v)} className={`whitespace-nowrap rounded-full px-4 py-2 text-xs ${status===v?'bg-black text-white':'bg-white text-black/55'}`}>{l}</button>)}</div><label className="relative"><Search className="absolute left-3 top-2.5 text-black/35" size={15}/><input value={q} onChange={e=>setQ(e.target.value)} className="w-full rounded-full border border-black/10 bg-white py-2 pl-9 pr-4 text-sm md:w-64" placeholder="搜索作品标题"/></label></section>
<div className="mt-8 grid grid-cols-2 gap-x-4 gap-y-8 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">{filtered.map((work,i)=><Link to={`/works/${work.id}`} key={work.id} className="group"><article><div className={`relative overflow-hidden rounded-2xl bg-[#ebe9e3] ${i%5===0?'aspect-[4/5]':'aspect-square'}`}>{work.cover_image?<img src={work.cover_image} alt={work.title} className="h-full w-full object-cover transition duration-700 group-hover:scale-105"/>:<div className="grid h-full place-items-center"><ImageIcon/></div>}<div className="absolute left-2 top-2"><StatusBadge status={work.review_status}/></div>{work.image_count>1&&<span className="absolute right-2 top-2 rounded-full bg-black/65 px-2 py-1 text-[10px] text-white">{work.image_count} </span>}</div><h3 className="mt-3 line-clamp-2 text-[15px] font-semibold leading-5">{work.title}</h3><div className="mt-2 flex items-center gap-3 text-[11px] text-black/40"><span className="flex items-center gap-1"><MessageCircle size={12}/>{work.annotation_count+work.comment_count}</span>{work.tags.slice(0,2).map(t=><span key={t}>#{t}</span>)}</div></article></Link>)}</div>
{editing&&<div className="fixed inset-0 z-[80] grid place-items-center bg-black/45 p-4"><div className="w-full max-w-md rounded-3xl bg-[#f7f6f2] p-7"><div className="flex items-center justify-between"><div><Pencil/><h3 className="mt-3 font-display text-3xl"></h3></div><button onClick={()=>setEditing(false)}><X/></button></div><label className="mt-7 block text-xs text-black/45"><input className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={editName} onChange={e=>setEditName(e.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={editDesc} onChange={e=>setEditDesc(e.target.value)}/></label><button disabled={!editName.trim()} onClick={async()=>{const updated=await api.updateCollection(pid,cid,{name:editName,client_description:editDesc});setCollection(updated);setEditing(false)}} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></div></div>}
</main>
export default function CollectionPage() {
const { projectId } = useParams();
return <Navigate to={`/projects/${projectId}`} replace />;
}

View File

@@ -1,81 +1,68 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, ArrowRight, Check, KeyRound, MessageSquareText, Send } from 'lucide-react';
import type { CustomerAccessState, Note, NoteDetail, Project, WorkCollection } from '@shared/types';
import { Link, Navigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, ArrowRight, Check, KeyRound } from 'lucide-react';
import type { CustomerAccessState, Note, NoteDetail, Project } from '@shared/types';
import { api, ApiError } from '@/api/client';
import AnnotatableImage from '@/components/AnnotatableImage';
import AnnotatableText from '@/components/AnnotatableText';
import AnnotatableText, { type TextSelectionDraft } from '@/components/AnnotatableText';
import CollaborationDrawer, { type DrawerTextDraft } from '@/components/CollaborationDrawer';
import ImageReviewModal from '@/components/ImageReviewModal';
import StatusBadge from '@/components/StatusBadge';
type ProjectPayload = { project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status'>; collections: WorkCollection[]; reviewer_name: string };
type ProjectPayload = { project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status' | 'review_status'>; works: Note[]; reviewer_name: string };
export default function CustomerReviewPage() {
const { slug = '', collectionId, noteId } = useParams();
const [search] = useSearchParams();
const selectedVersion = search.get('version');
const selectedRound = search.get('round') ? Number(search.get('round')) : undefined;
const [access, setAccess] = useState<CustomerAccessState | null>(null);
const [projectData, setProjectData] = useState<ProjectPayload | null>(null);
const [collectionData, setCollectionData] = useState<{ collection: WorkCollection; works: Note[] } | null>(null);
const [work, setWork] = useState<NoteDetail | null>(null);
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setError('');
try {
const state = await api.getCustomerAccess(slug);
setAccess(state);
if (!state.authenticated) return;
if (noteId) {
const result = await api.getCustomerWork(slug, Number(noteId), selectedVersion ? Number(selectedVersion) : undefined);
setWork({ ...result, text_annotations: result.text_annotations ?? [] });
}
else if (collectionId) setCollectionData(await api.getCustomerCollection(slug, Number(collectionId)));
else setProjectData(await api.getCustomerProject(slug));
} catch (reason) {
setError(reason instanceof ApiError ? reason.message : '页面加载失败');
}
}, [slug, collectionId, noteId, selectedVersion]);
const [name, setName] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [busy, setBusy] = useState(false);
const load = useCallback(async () => { setError(''); try { const state = await api.getCustomerAccess(slug); setAccess(state); if (!state.authenticated) return; if (noteId) setWork(await api.getCustomerWorkRound(slug, Number(noteId), selectedRound)); else setProjectData(await api.getCustomerProject(slug)); } catch (reason) { setError(reason instanceof ApiError ? reason.message : '页面加载失败'); } }, [slug, noteId, selectedRound]);
useEffect(() => { void load(); }, [load]);
const login = async (event: React.FormEvent) => { event.preventDefault(); setBusy(true); setError(''); try { await api.customerLogin(slug, { reviewer_name: name, password }); await load(); } catch (reason) { setError(reason instanceof ApiError ? reason.message : '验证失败'); } finally { setBusy(false); } };
const login = async (event: React.FormEvent) => {
event.preventDefault(); setBusy(true); setError('');
try { await api.customerLogin(slug, { reviewer_name: name, password }); await load(); }
catch (reason) { setError(reason instanceof ApiError ? reason.message : '验证失败'); }
finally { setBusy(false); }
};
if (collectionId) return <Navigate to={`/review/${slug}`} replace/>;
if (!access && !error) return <Centered text="正在打开验收空间…"/>;
if (!access) return <Centered text={error || '项目不存在'}/>;
if (!access.enabled || access.expired) return <Centered text={access.expired ? '此项目的访问链接已到期' : '此项目暂未开放客户访问'}/>;
if (!access.authenticated) return <AccessGate access={access} name={name} password={password} error={error} busy={busy} setName={setName} setPassword={setPassword} submit={login}/>;
if (noteId) return work ? <WorkReview slug={slug} work={work} reviewer={access.reviewer_name || '客户'} reload={load}/> : <Centered text={error || '正在加载作品…'}/>;
if (collectionId) return collectionData ? <CollectionReview slug={slug} data={collectionData}/> : <Centered text={error || '正在加载作品交付集…'}/>;
return projectData ? <ProjectReview slug={slug} data={projectData}/> : <Centered text={error || '正在加载项目…'}/>;
}
function AccessGate({ access, name, password, error, busy, setName, setPassword, submit }: { access: CustomerAccessState; name: string; password: string; error: string; busy: boolean; setName: (v: string) => void; setPassword: (v: string) => void; submit: (e: React.FormEvent) => void }) {
return <main className="grid min-h-screen place-items-center bg-[#f4f1ea] px-5 py-12"><form onSubmit={submit} className="w-full max-w-md rounded-[32px] border border-black/10 bg-white p-7 shadow-2xl shadow-black/5 sm:p-10"><div className="grid h-12 w-12 place-items-center rounded-full bg-[#171714] text-[#f3bd69]"><KeyRound size={18}/></div><p className="mt-8 font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Private review</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em]">{access.project_name}</h1><p className="mt-4 text-sm leading-7 text-black/45">{access.client_description || '请输入姓名与项目密码,进入本次作品验收。'}</p><label className="mt-8 block text-xs text-black/50"><input autoFocus value={name} onChange={(e)=>setName(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 px-4 py-3.5 text-sm outline-none focus:border-[#ba623c]"/></label><label className="mt-5 block text-xs text-black/50">访<input type="password" value={password} onChange={(e)=>setPassword(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 px-4 py-3.5 text-sm outline-none focus:border-[#ba623c]"/></label>{error&&<p className="mt-4 rounded-xl bg-red-50 p-3 text-xs text-red-700">{error}</p>}<button disabled={busy||name.trim().length<2||!password} className="mt-7 flex w-full items-center justify-center gap-2 rounded-full bg-[#171714] py-4 text-sm text-white disabled:opacity-30">{busy?'正在验证…':<><ArrowRight size={15}/></>}</button><p className="mt-5 text-center text-[11px] text-black/30"> 7 </p></form></main>;
function AccessGate({ access, name, password, error, busy, setName, setPassword, submit }: { access: CustomerAccessState; name: string; password: string; error: string; busy: boolean; setName: (value: string) => void; setPassword: (value: string) => void; submit: (event: React.FormEvent) => void }) {
return <main className="grid min-h-screen place-items-center bg-[#f4f1ea] px-5 py-12"><form onSubmit={submit} className="w-full max-w-md rounded-[32px] border border-black/10 bg-white p-7 shadow-2xl shadow-black/5 sm:p-10"><div className="grid h-12 w-12 place-items-center rounded-full bg-[#171714] text-[#f3bd69]"><KeyRound size={18}/></div><p className="mt-8 font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Private review</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em]">{access.project_name}</h1><p className="mt-4 text-sm leading-7 text-black/45">{access.client_description || '请输入姓名与项目密码,进入作品验收。'}</p><label className="mt-8 block text-xs text-black/50"><input autoFocus value={name} onChange={(event) => setName(event.target.value)} className="mt-2 w-full rounded-xl border border-black/10 px-4 py-3.5 text-sm"/></label><label className="mt-5 block text-xs text-black/50">访<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} className="mt-2 w-full rounded-xl border border-black/10 px-4 py-3.5 text-sm"/></label>{error && <p className="mt-4 rounded-xl bg-red-50 p-3 text-xs text-red-700">{error}</p>}<button disabled={busy || name.trim().length < 2 || !password} className="mt-7 flex w-full items-center justify-center gap-2 rounded-full bg-[#171714] py-4 text-sm text-white disabled:opacity-30">{busy ? '正在验证…' : <><ArrowRight size={15}/></>}</button></form></main>;
}
function ProjectReview({ slug, data }: { slug: string; data: ProjectPayload }) {
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={data.project.name} meta={`${data.reviewer_name} · 客户验收`}/><section className="mx-auto max-w-6xl px-5 py-12 lg:px-10"><p className="max-w-2xl text-sm leading-7 text-black/50">{data.project.client_description}</p><div className="mt-10 space-y-3">{data.collections.map((item, index)=><Link key={item.id} to={`/review/${slug}/collections/${item.id}`} className="group grid gap-4 rounded-2xl border border-black/10 bg-white p-5 md:grid-cols-[56px_1fr_auto] md:items-center"><div className="grid h-14 w-14 place-items-center rounded-xl bg-[#eeeae1] font-display text-xl">{String(index+1).padStart(2,'0')}</div><div><h2 className="font-display text-2xl">{item.name}</h2><p className="mt-1 text-sm text-black/40">{item.client_description || '作品交付集验收'}</p></div><div className="flex items-center gap-5 text-xs text-black/45"><span><b className="text-lg text-black">{item.approved_count}/{item.work_count}</b> </span><ArrowRight className="transition group-hover:translate-x-1"/></div></Link>)}</div></section></main>;
}
function CollectionReview({ slug, data }: { slug: string; data: { collection: WorkCollection; works: Note[] } }) {
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={data.collection.name} meta={`${data.works.length} 件作品`}/><section className="mx-auto max-w-[1500px] px-5 py-10 lg:px-10"><Link to={`/review/${slug}`} className="inline-flex items-center gap-2 text-xs text-black/40"><ArrowLeft size={13}/></Link><div className="mt-8 grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">{data.works.map((item)=><Link key={item.id} to={`/review/${slug}/works/${item.id}`} className="group overflow-hidden rounded-[24px] border border-black/10 bg-white"><div className="aspect-[4/5] overflow-hidden bg-black/5"><img src={item.cover_image} alt="" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.025]"/></div><div className="p-5"><div className="flex items-center justify-between gap-3"><h2 className="font-display text-2xl leading-tight">{item.title}</h2><StatusBadge status={item.review_status}/></div></div></Link>)}</div></section></main>;
const counts = { pending: data.works.filter((item) => item.review_status === 'pending').length, changes: data.works.filter((item) => item.review_status === 'changes_requested').length, approved: data.works.filter((item) => item.review_status === 'approved').length };
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={data.project.name} meta={`${data.reviewer_name} · 客户验收`}/><section className="mx-auto max-w-[1500px] px-5 py-10 lg:px-10"><div className="flex flex-col gap-6 border-b border-black/10 pb-8 md:flex-row md:items-end md:justify-between"><div><p className="max-w-2xl text-sm leading-7 text-black/50">{data.project.client_description}</p><span className="mt-4 inline-block rounded-full bg-black px-3 py-1.5 text-[10px] text-white">{data.project.review_status === 'completed' ? '验收完毕' : data.project.review_status === 'reviewing' ? '验收中' : '待提交'}</span></div><div className="flex gap-7 text-right"><Metric n={counts.pending} label="待验收"/><Metric n={counts.changes} label="需修改"/><Metric n={counts.approved} label="已通过"/></div></div>{data.project.review_status === 'completed' && <div className="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-800"></div>}<div className="mt-8 grid grid-cols-2 gap-x-4 gap-y-8 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">{data.works.map((item) => <Link key={item.id} to={`/review/${slug}/works/${item.id}`} className="group"><div className="relative aspect-[4/5] overflow-hidden rounded-[22px] bg-black/5"><img src={item.cover_image} alt="" className="h-full w-full object-cover transition duration-700 group-hover:scale-105"/><div className="absolute left-2 top-2"><StatusBadge status={item.review_status}/></div></div><h2 className="mt-3 font-display text-[22px] leading-6">{item.title}</h2><p className="mt-1 text-[11px] text-black/35"> {item.version_number} </p></Link>)}</div></section></main>;
}
function WorkReview({ slug, work, reviewer, reload }: { slug: string; work: NoteDetail; reviewer: string; reload: () => Promise<void> }) {
const [comment,setComment]=useState(''); const [reason,setReason]=useState(''); const [busy,setBusy]=useState(false);
const send=async()=>{if(!comment.trim())return;setBusy(true);await api.addCustomerComment(slug,work.id,comment.trim());setComment('');await reload();setBusy(false)};
const decide=async(decision:'approved'|'changes_requested')=>{if(decision==='changes_requested'&&!reason.trim())return;if(decision==='approved'&&!window.confirm('确认通过这个版本吗?通过后将记录你的验收决定。'))return;setBusy(true);await api.submitCustomerDecision(slug,work.id,decision,reason.trim());setReason('');await reload();setBusy(false)};
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={work.project.name} meta={`${reviewer} · V${work.version_number}`}/><div className="mx-auto grid max-w-[1500px] lg:grid-cols-[minmax(0,1fr)_360px]"><article className="min-w-0 px-5 py-9 lg:px-10 lg:py-12"><div className="flex flex-wrap items-center justify-between gap-3"><Link to={`/review/${slug}/collections/${work.collection.id}`} className="inline-flex items-center gap-2 text-xs text-black/40"><ArrowLeft size={13}/>{work.collection.name}</Link><div className="flex gap-2">{work.versions.map((item)=><Link key={item.version_number} to={`/review/${slug}/works/${work.id}?version=${item.version_number}`} className={`rounded-full px-3 py-1.5 text-[11px] ${item.version_number===work.version_number?'bg-black text-white':'bg-black/5'}`}>V{item.version_number}</Link>)}</div></div><p className="mb-5 mt-8 text-xs text-black/45">{work.project.name} <span className="mx-2 text-black/20">/</span> {work.collection.name} <span className="mx-2 text-black/20">/</span> <b className="font-mono text-[#ba623c]">WORK {String(work.id).padStart(3,'0')}</b></p><div className="columns-1 gap-5 xl:columns-2">{work.images.map((img)=><AnnotatableImage key={img.id} image={img} annotations={img.annotations} onAdd={async(x,y,content)=>{await api.addCustomerAnnotation(slug,img.id,{x,y,content});await reload()}}/>)}</div><div className="mt-12 border-t border-black/10 pt-9"><AnnotatableText label="标题" annotations={work.text_annotations.filter((annotation)=>annotation.target==='title')} onAdd={async(content)=>{await api.addCustomerTextAnnotation(slug,work.id,{version_number:work.version_number,target:'title',content});await reload()}}><h1 className="font-display text-4xl leading-[1.08] tracking-[-.04em] md:text-5xl">{work.title}</h1></AnnotatableText>{work.description&&<div className="mt-7"><AnnotatableText label="正文" annotations={work.text_annotations.filter((annotation)=>annotation.target==='description')} onAdd={async(content)=>{await api.addCustomerTextAnnotation(slug,work.id,{version_number:work.version_number,target:'description',content});await reload()}}><p className="max-w-3xl whitespace-pre-wrap text-[15px] leading-8 text-black/65">{work.description}</p></AnnotatableText></div>}{work.tags.length>0&&<p className="mt-7 max-w-3xl whitespace-pre-wrap text-[15px] leading-8 text-black/65">{work.tags.join(' ')}</p>}</div></article><aside className="border-t border-black/10 bg-[#efebe3] lg:sticky lg:top-0 lg:h-screen lg:border-l lg:border-t-0"><div className="flex h-full flex-col"><div className="border-b border-black/10 p-5"><p className="font-mono text-[10px] uppercase tracking-[.25em] text-black/35">Review</p><h2 className="mt-2 font-display text-3xl"></h2></div><div className="flex-1 space-y-3 overflow-auto p-4">{work.comments.length===0&&<div className="py-10 text-center text-xs text-black/35"><MessageSquareText className="mx-auto mb-3"/></div>}{work.comments.map((item)=><div key={item.id} className={`rounded-2xl p-3 text-sm ${item.author_role==='operator'?'ml-5 bg-black text-white':'mr-5 bg-white'}`}><div className="mb-2 text-[10px] opacity-45">{item.author_name}</div><p className="whitespace-pre-wrap leading-6">{item.content}</p></div>)}</div><div className="border-t border-black/10 p-4"><div className="relative"><textarea rows={3} value={comment} onChange={(e)=>setComment(e.target.value)} className="w-full resize-none rounded-2xl border border-black/10 bg-white p-3 pr-12 text-sm" placeholder="针对整个作品留下意见…"/><button disabled={busy||!comment.trim()} onClick={()=>void send()} className="absolute bottom-3 right-3 rounded-full bg-black p-2 text-white disabled:opacity-30"><Send size={14}/></button></div>{work.review_status!=='approved'&&<><textarea rows={2} value={reason} onChange={(e)=>setReason(e.target.value)} className="mt-3 w-full resize-none rounded-xl border border-black/10 bg-white p-3 text-xs" placeholder="要求修改时,请填写原因"/><div className="mt-2 grid grid-cols-2 gap-2"><button disabled={busy||!reason.trim()} onClick={()=>void decide('changes_requested')} className="rounded-full border border-[#ba623c]/30 bg-white py-3 text-xs text-[#a94e2c] disabled:opacity-30"></button><button disabled={busy} onClick={()=>void decide('approved')} className="flex items-center justify-center gap-2 rounded-full bg-emerald-600 py-3 text-xs text-white"><Check size={14}/></button></div></>}</div></div></aside></div></main>;
const [drawerOpen, setDrawerOpen] = useState(false); const [reason, setReason] = useState(''); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState('');
const [selectedImage, setSelectedImage] = useState<{ imageId: number } | null>(null);
const [focusImageId, setFocusImageId] = useState<number>();
const [textDraft, setTextDraft] = useState<(DrawerTextDraft & { target: 'title' | 'description' | 'tags' }) | null>(null);
const [focusFeedback, setFocusFeedback] = useState<{ type: 'text_annotation' | 'image_annotation'; id: number } | null>(null);
const viewed = work.rounds.find((round) => round.version_number === work.version_number);
const current = Boolean(viewed && Number(viewed.review_round_id) === Number(work.active_round_id));
const readOnly = work.project.status !== 'active' || work.project.review_status === 'completed' || !current || viewed?.round_status !== 'reviewing';
const tagsText = work.tags.join(' ');
const selectText = (target: 'title' | 'description' | 'tags', label: string, selection: TextSelectionDraft) => { setTextDraft({ target, label, ...selection }); setFocusImageId(undefined); setFocusFeedback(null); setDrawerOpen(true); };
const openTextAnnotation = (annotationId: number) => { setTextDraft(null); setFocusImageId(undefined); setFocusFeedback({ type: 'text_annotation', id: annotationId }); setDrawerOpen(true); };
const openImage = (imageId: number) => { setSelectedImage({ imageId }); setFocusImageId(imageId); setTextDraft(null); setFocusFeedback(null); };
const openImageAnnotation = (imageId: number, annotationId: number) => { setFocusImageId(imageId); setTextDraft(null); setFocusFeedback({ type: 'image_annotation', id: annotationId }); setDrawerOpen(true); };
const openFeedback = work.images.flatMap((image) => image.annotations).filter((item) => item.status === 'open').length + work.text_annotations.filter((item) => item.status === 'open').length + work.comments.filter((item) => item.status === 'open').length;
const decide = async (decision: 'approved' | 'changes_requested') => { if (!viewed || decision === 'changes_requested' && !reason.trim()) return; if (decision === 'approved' && !window.confirm(openFeedback ? `当前还有 ${openFeedback} 条未处理反馈。确认通过并将其标记为“随本轮通过关闭”吗?` : '确认通过当前轮次吗?')) return; setBusy(true); setActionError(''); try { await api.submitCustomerRoundDecision(slug, work.id, viewed.round_number, decision, reason.trim()); setReason(''); await reload(); } catch (error) { setActionError(error instanceof Error ? error.message : '验收提交失败'); } finally { setBusy(false); } };
const footer = !readOnly ? <div className="mt-3"><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} className="w-full resize-none rounded-xl border border-black/10 bg-white p-3 text-xs" placeholder="要求修改时,请填写原因"/><div className="mt-2 grid grid-cols-2 gap-2"><button disabled={busy || !reason.trim()} onClick={() => void decide('changes_requested')} className="rounded-full border border-[#ba623c]/30 bg-white py-3 text-xs text-[#a94e2c] disabled:opacity-30"></button><button disabled={busy} onClick={() => void decide('approved')} className="flex items-center justify-center gap-2 rounded-full bg-emerald-600 py-3 text-xs text-white"><Check size={14}/></button></div>{actionError && <p className="mt-3 rounded-xl bg-red-50 p-3 text-xs text-red-700">{actionError}</p>}</div> : null;
return <main onClick={() => { if (drawerOpen) setDrawerOpen(false); }} className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={work.project.name} meta={`${reviewer} · 第 ${viewed?.round_number ?? '-'}`}/><article className="mx-auto max-w-[1280px] px-5 py-9 lg:px-10 lg:py-12"><div className="flex flex-wrap items-center justify-between gap-3"><Link to={`/review/${slug}`} className="inline-flex items-center gap-2 text-xs text-black/40"><ArrowLeft size={13}/></Link><div className="flex flex-wrap gap-2">{work.rounds.map((round) => <Link key={round.round_number} to={`/review/${slug}/works/${work.id}?round=${round.round_number}`} className={`rounded-full px-3 py-1.5 text-[11px] ${round.round_number === viewed?.round_number ? 'bg-black text-white' : 'bg-black/5'}`}> {round.round_number} </Link>)}</div></div>{!current && <div className="mt-6 rounded-2xl border border-black/10 bg-black/[.03] px-5 py-4 text-sm text-black/55"></div>}<p className="mb-5 mt-8 text-xs text-black/45">{work.project.name}<span className="mx-2 text-black/20">/</span>Work {String(work.id).padStart(3, '0')}<span className="mx-2 text-black/20">/</span> {viewed?.round_number} </p><div className="columns-1 gap-5 xl:columns-2">{work.images.map((image) => <AnnotatableImage key={image.id} image={image} annotations={image.annotations} readOnly onAdd={async () => undefined} onOpen={() => openImage(image.id)} onAnnotationOpen={(annotationId) => openImageAnnotation(image.id, annotationId)}/>)}</div><div className="mt-12 border-t border-black/10 pt-9"><AnnotatableText text={work.title} label="标题" variant="title" annotations={work.text_annotations.filter((item) => item.target === 'title')} readOnly={readOnly} onSelect={(selection) => selectText('title', '标题', selection)} onOpenAnnotation={openTextAnnotation}/>{work.description && <div className="mt-9"><AnnotatableText text={work.description} label="正文" annotations={work.text_annotations.filter((item) => item.target === 'description')} readOnly={readOnly} onSelect={(selection) => selectText('description', '正文', selection)} onOpenAnnotation={openTextAnnotation}/></div>}{tagsText && <div className="mt-8"><AnnotatableText text={tagsText} label="Tag" annotations={work.text_annotations.filter((item) => item.target === 'tags')} readOnly={readOnly} onSelect={(selection) => selectText('tags', 'Tag', selection)} onOpenAnnotation={openTextAnnotation}/></div>}</div></article><CollaborationDrawer work={work} open={drawerOpen} setOpen={setDrawerOpen} readOnly={readOnly} actorName={reviewer} actorRole="client" focusImageId={focusImageId} textDraft={textDraft} focusFeedback={focusFeedback} onCancelTextAnnotation={() => setTextDraft(null)} onTextAnnotation={async (content) => { if (!textDraft || !viewed) return; await api.addCustomerTextSelectionAnnotation(slug, work.id, { round_number: viewed.round_number, target: textDraft.target, start_offset: textDraft.start, end_offset: textDraft.end, selected_text: textDraft.text, content }); setTextDraft(null); await reload(); }} onComment={async (content) => { await api.addCustomerComment(slug, work.id, content); await reload(); }} onReply={async (type, feedbackId, content) => { await api.replyToCustomerFeedback(slug, work.id, type, feedbackId, content); await reload(); }} onWithdraw={async (type, feedbackId) => { await api.withdrawCustomerFeedback(slug, work.id, type, feedbackId); await reload(); }} footer={footer}/>{selectedImage && <ImageReviewModal work={work} initialImageId={selectedImage.imageId} readOnly={readOnly} slug={slug} onClose={() => { setSelectedImage(null); setDrawerOpen(false); }} onReload={reload} onImageChange={(imageId) => { setSelectedImage({ imageId }); setFocusImageId(imageId); setFocusFeedback(null); }} onOpenAnnotation={openImageAnnotation}/>}</main>;
}
function ReviewHeader({title,meta}:{title:string;meta:string}) { return <header className="border-b border-white/10 bg-[#171714] text-white"><div className="mx-auto flex max-w-[1500px] items-center justify-between px-5 py-5 lg:px-10"><div><p className="font-mono text-[9px] uppercase tracking-[.25em] text-[#f3bd69]">Delivery Desk</p><h1 className="mt-1 font-display text-2xl">{title}</h1></div><span className="rounded-full border border-white/15 px-3 py-1.5 text-[10px] text-white/55">{meta}</span></div></header> }
function Centered({text}:{text:string}) { return <main className="grid min-h-screen place-items-center bg-[#f4f1ea] px-5 text-center text-sm text-black/45">{text}</main> }
function ReviewHeader({ title, meta }: { title: string; meta: string }) { return <header className="border-b border-white/10 bg-[#171714] text-white"><div className="mx-auto flex max-w-[1500px] items-center justify-between px-5 py-5 lg:px-10"><div><p className="font-mono text-[9px] uppercase tracking-[.25em] text-[#f3bd69]">Delivery Desk</p><h1 className="mt-1 font-display text-2xl">{title}</h1></div><span className="rounded-full border border-white/15 px-3 py-1.5 text-[10px] text-white/55">{meta}</span></div></header>; }
function Metric({ n, label }: { n: number; label: string }) { return <div><b className="font-display text-3xl">{n}</b><span className="mt-1 block text-[10px] text-black/35">{label}</span></div>; }
function Centered({ text }: { text: string }) { return <main className="grid min-h-screen place-items-center bg-[#f4f1ea] px-5 text-center text-sm text-black/45">{text}</main>; }

View File

@@ -6,29 +6,68 @@ import { api } from '@/api/client';
import { useAuthStore } from '@/store/useAuthStore';
export default function Dashboard() {
const [projects,setProjects]=useState<Project[]>([]); const [groups,setGroups]=useState<OperationGroup[]>([]); const [accounts,setAccounts]=useState<ManagedUser[]>([]); const [storage,setStorage]=useState<StorageConfig[]>([]); const [logs,setLogs]=useState<AuditLogEntry[]>([]);
const [creating,setCreating]=useState(false); const [editingGroup,setEditingGroup]=useState(false); const [groupName,setGroupName]=useState(''); const [form,setForm]=useState({name:'',slug:'',client_description:'',groupId:''});
const {user,initialize}=useAuthStore();
const load=()=>api.listProjects().then(setProjects);
useEffect(()=>{void load();if(user?.role==='platform_admin')void Promise.all([api.listGroups(),api.listManagedUsers(),api.listStorageConfigs(),api.listAuditLogs()]).then(([g,a,s,l])=>{setGroups(g);setAccounts(a);setStorage(s);setLogs(l)});else if(user?.role==='group_admin')void api.listGroups().then(setGroups)},[user?.role]);
const activeStorage=storage.find((item)=>item.status==='active');
const metrics=useMemo(()=>[{label:'运营组',value:groups.filter((item)=>item.status==='active').length,icon:<Users size={18}/>},{label:'有效账号',value:accounts.filter((item)=>item.status==='active').length,icon:<ShieldCheck size={18}/>},{label:'进行中项目',value:projects.filter((item)=>item.status==='active').length,icon:<FolderKanban size={18}/>},{label:'对象存储',value:activeStorage?'已连接':'未配置',icon:<Database size={18}/>}],[groups,accounts,projects,activeStorage]);
const create=async()=>{await api.createProject({...form,groupId:form.groupId?Number(form.groupId):undefined});setCreating(false);setForm({name:'',slug:'',client_description:'',groupId:''});await load()};
const [projects, setProjects] = useState<Project[]>([]);
const [groups, setGroups] = useState<OperationGroup[]>([]);
const [accounts, setAccounts] = useState<ManagedUser[]>([]);
const [storage, setStorage] = useState<StorageConfig[]>([]);
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [creating, setCreating] = useState(false);
const [editingGroup, setEditingGroup] = useState(false);
const [groupName, setGroupName] = useState('');
const [form, setForm] = useState({ name: '', slug: '', client_description: '', groupId: '' });
const { user, initialize } = useAuthStore();
const load = () => api.listProjects().then(setProjects);
useEffect(() => {
void load();
if (user?.role === 'platform_admin') {
void Promise.all([api.listGroups(), api.listManagedUsers(), api.listStorageConfigs(), api.listAuditLogs()])
.then(([groupRows, accountRows, storageRows, logRows]) => {
setGroups(groupRows); setAccounts(accountRows); setStorage(storageRows); setLogs(logRows);
});
} else if (user?.role === 'group_admin') {
void api.listGroups().then(setGroups);
}
}, [user?.role]);
const activeStorage = storage.find((item) => item.status === 'active');
const metrics = useMemo(() => [
{ label: '运营组', value: groups.filter((item) => item.status === 'active').length, icon: <Users size={18} /> },
{ label: '有效账号', value: accounts.filter((item) => item.status === 'active').length, icon: <ShieldCheck size={18} /> },
{ label: '进行中项目', value: projects.filter((item) => item.status === 'active').length, icon: <FolderKanban size={18} /> },
{ label: '对象存储', value: activeStorage ? '已连接' : '未配置', icon: <Database size={18} /> },
], [groups, accounts, projects, activeStorage]);
const create = async () => {
await api.createProject({ ...form, groupId: form.groupId ? Number(form.groupId) : undefined });
setCreating(false);
setForm({ name: '', slug: '', client_description: '', groupId: '' });
await load();
};
return <main className="mx-auto max-w-[1500px] px-5 py-10 lg:px-10 lg:py-14">
{user?.role==='platform_admin'?<>
<section className="grid gap-8 border-b border-black/10 pb-10 lg:grid-cols-[1fr_auto] lg:items-end"><div><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Platform / Governance</p><h1 className="mt-4 max-w-4xl font-display text-5xl leading-[.96] tracking-[-.055em] sm:text-7xl"><br/><em className="font-light text-black/35"></em></h1></div><div className="flex flex-wrap gap-2"><Link to="/management" className="inline-flex items-center gap-2 rounded-full border border-black/15 bg-white px-5 py-3 text-sm"><ShieldCheck size={15}/></Link><button onClick={()=>setCreating(true)} className="inline-flex items-center gap-2 rounded-full bg-black px-5 py-3 text-sm text-white"><Plus size={15}/></button></div></section>
<section className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">{metrics.map((item)=><div key={item.label} className="rounded-[24px] border border-black/[.08] bg-white p-5"><div className="flex items-center justify-between text-black/35"><span className="grid h-9 w-9 place-items-center rounded-full bg-[#f0ede6]">{item.icon}</span><span className="font-mono text-[9px] uppercase tracking-[.2em]">Live</span></div><strong className="mt-7 block font-display text-4xl">{item.value}</strong><span className="mt-1 block text-xs text-black/40">{item.label}</span></div>)}</section>
<section className="mt-8 grid gap-5 xl:grid-cols-[1.25fr_.75fr]"><div className="rounded-[28px] border border-black/[.08] bg-white p-6"><div className="flex items-center justify-between"><div><p className="font-mono text-[9px] uppercase tracking-[.22em] text-black/35">Projects</p><h2 className="mt-2 font-display text-3xl"></h2></div><span className="text-xs text-black/35">{projects.length} </span></div><div className="mt-6 grid gap-3 sm:grid-cols-2">{projects.slice(0,6).map((project)=><Link key={project.id} to={`/projects/${project.id}`} className="group rounded-2xl border border-black/10 p-4 transition hover:border-black/30"><div className="flex justify-between"><FolderKanban size={16}/><ArrowUpRight size={15} className="text-black/25 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5"/></div><div className="mt-5 inline-flex items-center gap-1.5 rounded-full bg-[#f3ece5] px-2.5 py-1 text-[10px] text-[#9b5236]"><Users size={11}/>{project.group_name}</div><h3 className="mt-3 font-display text-2xl">{project.name}</h3><p className="mt-2 text-xs text-black/40">{project.collection_count} · {project.work_count} </p></Link>)}</div></div><div className="rounded-[28px] bg-[#171714] p-6 text-white"><div className="flex items-center gap-2 text-white/40"><Activity size={15}/><span className="font-mono text-[9px] uppercase tracking-[.22em]">Recent activity</span></div><h2 className="mt-3 font-display text-3xl"></h2><div className="mt-6 space-y-4">{logs.slice(0,6).map((log)=><div key={log.id} className="border-b border-white/10 pb-3"><p className="text-xs text-white/75">{log.action}</p><p className="mt-1 text-[10px] text-white/30">{log.user_name||'系统'} · {new Date(log.created_at).toLocaleString('zh-CN')}</p></div>)}{logs.length===0&&<p className="py-10 text-center text-xs text-white/30"></p>}</div></div></section>
</>:<>
<section className="grid gap-10 border-b border-black/10 pb-12 lg:grid-cols-[1fr_auto] lg:items-end"><div><div className="mb-4 flex flex-wrap items-center gap-3"><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Operations / {user?.group_name}</p>{user?.role==='group_admin'&&<button onClick={()=>{setGroupName(user.group_name||'');setEditingGroup(true)}} className="inline-flex items-center gap-1 text-[10px] text-black/35 hover:text-black"><Pencil size={10}/></button>}</div><h1 className="max-w-4xl font-display text-5xl leading-[.95] tracking-[-.055em] sm:text-7xl lg:text-[92px]"><br/><em className="font-light text-black/35"></em></h1></div><button onClick={()=>setCreating(true)} className="flex items-center justify-center gap-2 rounded-full bg-[#171714] px-6 py-3.5 text-sm text-white hover:bg-[#ba623c]"><Plus size={17}/></button></section>
<ProjectGrid projects={projects}/>
{user?.role === 'platform_admin' ? <>
<section className="grid gap-8 border-b border-black/10 pb-10 lg:grid-cols-[1fr_auto] lg:items-end">
<div><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Platform / Governance</p><h1 className="mt-4 max-w-4xl font-display text-5xl leading-[.96] tracking-[-.055em] sm:text-7xl"><br /><em className="font-light text-black/35"></em></h1></div>
<div className="flex flex-wrap gap-2"><Link to="/management" className="inline-flex items-center gap-2 rounded-full border border-black/15 bg-white px-5 py-3 text-sm"><ShieldCheck size={15} /></Link><button onClick={() => setCreating(true)} className="inline-flex items-center gap-2 rounded-full bg-black px-5 py-3 text-sm text-white"><Plus size={15} /></button></div>
</section>
<section className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">{metrics.map((item) => <div key={item.label} className="rounded-[24px] border border-black/[.08] bg-white p-5"><div className="flex items-center justify-between text-black/35"><span className="grid h-9 w-9 place-items-center rounded-full bg-[#f0ede6]">{item.icon}</span><span className="font-mono text-[9px] uppercase tracking-[.2em]">Live</span></div><strong className="mt-7 block font-display text-4xl">{item.value}</strong><span className="mt-1 block text-xs text-black/40">{item.label}</span></div>)}</section>
<section className="mt-8 grid gap-5 xl:grid-cols-[1.25fr_.75fr]">
<div className="rounded-[28px] border border-black/[.08] bg-white p-6"><div className="flex items-center justify-between"><div><p className="font-mono text-[9px] uppercase tracking-[.22em] text-black/35">Projects</p><h2 className="mt-2 font-display text-3xl"></h2></div><span className="text-xs text-black/35">{projects.length} </span></div><div className="mt-6 grid gap-3 sm:grid-cols-2">{projects.slice(0, 6).map((project) => <Link key={project.id} to={`/projects/${project.id}`} className="group rounded-2xl border border-black/10 p-4 transition hover:border-black/30"><div className="flex justify-between"><FolderKanban size={16} /><ArrowUpRight size={15} className="text-black/25 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" /></div><div className="mt-5 inline-flex items-center gap-1.5 rounded-full bg-[#f3ece5] px-2.5 py-1 text-[10px] text-[#9b5236]"><Users size={11} />{project.group_name}</div><h3 className="mt-3 font-display text-2xl">{project.name}</h3><p className="mt-2 text-xs text-black/40">{project.work_count} · {project.approved_count} </p></Link>)}</div></div>
<div className="rounded-[28px] bg-[#171714] p-6 text-white"><div className="flex items-center gap-2 text-white/40"><Activity size={15} /><span className="font-mono text-[9px] uppercase tracking-[.22em]">Recent activity</span></div><h2 className="mt-3 font-display text-3xl"></h2><div className="mt-6 space-y-4">{logs.slice(0, 6).map((log) => <div key={log.id} className="border-b border-white/10 pb-3"><p className="text-xs text-white/75">{log.action}</p><p className="mt-1 text-[10px] text-white/30">{log.user_name || '系统'} · {new Date(log.created_at).toLocaleString('zh-CN')}</p></div>)}{logs.length === 0 && <p className="py-10 text-center text-xs text-white/30"></p>}</div></div>
</section>
</> : <>
<section className="grid gap-10 border-b border-black/10 pb-12 lg:grid-cols-[1fr_auto] lg:items-end"><div><div className="mb-4 flex flex-wrap items-center gap-3"><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Operations / {user?.group_name}</p>{user?.role === 'group_admin' && <button onClick={() => { setGroupName(user.group_name || ''); setEditingGroup(true); }} className="inline-flex items-center gap-1 text-[10px] text-black/35 hover:text-black"><Pencil size={10} /></button>}</div><h1 className="max-w-4xl font-display text-5xl leading-[.95] tracking-[-.055em] sm:text-7xl lg:text-[92px]"><br /><em className="font-light text-black/35"></em></h1></div><button onClick={() => setCreating(true)} className="flex items-center justify-center gap-2 rounded-full bg-[#171714] px-6 py-3.5 text-sm text-white hover:bg-[#ba623c]"><Plus size={17} /></button></section>
<ProjectGrid projects={projects} />
</>}
{creating&&<Modal close={()=>setCreating(false)} title="创建项目" icon={<FolderKanban/>}><div className="mt-7 space-y-5">{user?.role==='platform_admin'&&<Field label="所属运营组"><select value={form.groupId} onChange={(e)=>setForm({...form,groupId:e.target.value})}><option value=""></option>{groups.filter((item)=>item.status==='active').map((item)=><option key={item.id} value={item.id}>{item.name}</option>)}</select></Field>}<Field label="项目名称"><input value={form.name} onChange={(e)=>setForm({...form,name:e.target.value})}/></Field><Field label="项目标识"><input placeholder="project-slug" value={form.slug} onChange={(e)=>setForm({...form,slug:e.target.value})}/></Field><Field label="客户页简介"><textarea rows={3} value={form.client_description} onChange={(e)=>setForm({...form,client_description:e.target.value})}/></Field></div><button disabled={!form.name||!form.slug||(user?.role==='platform_admin'&&!form.groupId)} onClick={()=>void create()} className="mt-7 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
{editingGroup&&<Modal close={()=>setEditingGroup(false)} title="修改运营组名称" icon={<Pencil/>}><p className="mt-3 text-sm leading-6 text-black/45"></p><input autoFocus value={groupName} onChange={(e)=>setGroupName(e.target.value)} className="mt-6 w-full rounded-xl border border-black/10 bg-white p-3" placeholder="请输入运营组名称"/><button disabled={groupName.trim().length<2} onClick={async()=>{await api.updateCurrentGroup(groupName.trim());await initialize();setEditingGroup(false)}} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
{creating && <Modal close={() => setCreating(false)} title="创建项目" icon={<FolderKanban />}><div className="mt-7 space-y-5">{user?.role === 'platform_admin' && <Field label="所属运营组"><select value={form.groupId} onChange={(event) => setForm({ ...form, groupId: event.target.value })}><option value=""></option>{groups.filter((item) => item.status === 'active').map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></Field>}<Field label="项目名称"><input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field><Field label="项目标识"><input placeholder="project-slug" value={form.slug} onChange={(event) => setForm({ ...form, slug: event.target.value })} /></Field><Field label="客户页简介"><textarea rows={3} value={form.client_description} onChange={(event) => setForm({ ...form, client_description: event.target.value })} /></Field></div><button disabled={!form.name || !form.slug || (user?.role === 'platform_admin' && !form.groupId)} onClick={() => void create()} className="mt-7 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
{editingGroup && <Modal close={() => setEditingGroup(false)} title="修改运营组名称" icon={<Pencil />}><p className="mt-3 text-sm leading-6 text-black/45"></p><input autoFocus value={groupName} onChange={(event) => setGroupName(event.target.value)} className="mt-6 w-full rounded-xl border border-black/10 bg-white p-3" placeholder="请输入运营组名称" /><button disabled={groupName.trim().length < 2} onClick={async () => { await api.updateCurrentGroup(groupName.trim()); await initialize(); setEditingGroup(false); }} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
</main>;
}
function ProjectGrid({projects}:{projects:Project[]}){return <><div className="mb-6 mt-10 flex items-center justify-between"><h2 className="font-display text-3xl">项目</h2><span className="font-mono text-xs text-black/40">{projects.length} ACTIVE</span></div><section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{projects.map((project,index)=><Link key={project.id} to={`/projects/${project.id}`} className="group relative min-h-64 overflow-hidden rounded-[28px] border border-black/10 bg-white p-7 transition hover:-translate-y-1 hover:shadow-2xl hover:shadow-black/10"><div className="absolute right-0 top-0 h-36 w-36 translate-x-12 -translate-y-12 rounded-full" style={{background:index%3===0?'#ffddd2':index%3===1?'#dcece6':'#f6e8b9'}}/><div className="relative flex h-full flex-col"><div className="flex items-center justify-between"><FolderKanban size={20}/><ArrowUpRight className="opacity-30 transition group-hover:translate-x-1 group-hover:-translate-y-1 group-hover:opacity-100"/></div><div className="mt-auto"><h3 className="font-display text-3xl tracking-tight">{project.name}</h3><p className="mt-2 line-clamp-2 text-sm leading-6 text-black/50">{project.client_description||'暂无项目说明'}</p><div className="mt-6 flex gap-5 border-t border-black/10 pt-4 text-xs text-black/50"><span><b className="text-black">{project.collection_count}</b> </span><span><b className="text-black">{project.work_count}</b> </span></div></div></div></Link>)}</section></>}
function Modal({close,title,icon,children}:{close:()=>void;title:string;icon:React.ReactNode;children:React.ReactNode}){return <div className="fixed inset-0 z-[80] grid place-items-center bg-black/45 p-4 backdrop-blur-sm"><div className="w-full max-w-lg rounded-[28px] bg-[#f7f6f2] p-7 shadow-2xl"><div className="flex items-center justify-between"><div>{icon}<h3 className="mt-3 font-display text-3xl">{title}</h3></div><button onClick={close}><X/></button></div>{children}</div></div>}
function Field({label,children}:{label:string;children:React.ReactNode}){return <label className="block text-xs font-medium text-black/55">{label}<div className="mt-2 [&_input]:w-full [&_input]:rounded-xl [&_input]:border [&_input]:border-black/10 [&_input]:bg-white [&_input]:p-3 [&_select]:w-full [&_select]:rounded-xl [&_select]:border [&_select]:border-black/10 [&_select]:bg-white [&_select]:p-3 [&_textarea]:w-full [&_textarea]:rounded-xl [&_textarea]:border [&_textarea]:border-black/10 [&_textarea]:bg-white [&_textarea]:p-3">{children}</div></label>}
function ProjectGrid({ projects }: { projects: Project[] }) {
return <><div className="mb-6 mt-10 flex items-center justify-between"><h2 className="font-display text-3xl"></h2><span className="font-mono text-xs text-black/40">{projects.length} ACTIVE</span></div><section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{projects.map((project, index) => <Link key={project.id} to={`/projects/${project.id}`} className="group relative min-h-64 overflow-hidden rounded-[28px] border border-black/10 bg-white p-7 transition hover:-translate-y-1 hover:shadow-2xl hover:shadow-black/10"><div className="absolute right-0 top-0 h-36 w-36 translate-x-12 -translate-y-12 rounded-full" style={{ background: index % 3 === 0 ? '#ffddd2' : index % 3 === 1 ? '#dcece6' : '#f6e8b9' }} /><div className="relative flex h-full flex-col"><div className="flex items-center justify-between"><FolderKanban size={20} /><ArrowUpRight className="opacity-30 transition group-hover:translate-x-1 group-hover:-translate-y-1 group-hover:opacity-100" /></div><div className="mt-auto"><h3 className="font-display text-3xl tracking-tight">{project.name}</h3><p className="mt-2 line-clamp-2 text-sm leading-6 text-black/50">{project.client_description || '暂无项目说明'}</p><div className="mt-6 flex gap-5 border-t border-black/10 pt-4 text-xs text-black/50"><span><b className="text-black">{project.work_count}</b> </span><span><b className="text-black">{project.approved_count}</b> </span></div></div></div></Link>)}</section></>;
}
function Modal({ close, title, icon, children }: { close: () => void; title: string; icon: React.ReactNode; children: React.ReactNode }) { return <div className="fixed inset-0 z-[80] grid place-items-center bg-black/45 p-4 backdrop-blur-sm"><div className="w-full max-w-lg rounded-[28px] bg-[#f7f6f2] p-7 shadow-2xl"><div className="flex items-center justify-between"><div>{icon}<h3 className="mt-3 font-display text-3xl">{title}</h3></div><button onClick={close}><X /></button></div>{children}</div></div>; }
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <label className="block text-xs font-medium text-black/55">{label}<div className="mt-2 [&_input]:w-full [&_input]:rounded-xl [&_input]:border [&_input]:border-black/10 [&_input]:bg-white [&_input]:p-3 [&_select]:w-full [&_select]:rounded-xl [&_select]:border [&_select]:border-black/10 [&_select]:bg-white [&_select]:p-3 [&_textarea]:w-full [&_textarea]:rounded-xl [&_textarea]:border [&_textarea]:border-black/10 [&_textarea]:bg-white [&_textarea]:p-3">{children}</div></label>; }

View File

@@ -13,8 +13,8 @@ const actionLabel: Record<string, string> = {
'group.create': '创建运营组', 'group.active': '启用运营组', 'group.disabled': '停用运营组',
'group.admin_replace':'更换组管理员','group.rename':'修改运营组名称','user.create': '创建账号', 'user.active': '启用账号', 'user.disabled': '停用账号', 'user.password_reset': '重置密码','user.name_update':'修改用户名',
'api_key.create': '创建 API Key', 'api_key.revoke': '吊销 API Key',
'project.create': '创建项目', 'project.update': '修改项目', 'collection.create': '创建作品交付集',
'collection.update': '修改作品交付集', 'work.create': '上传作品',
'project.create': '创建项目', 'project.update': '修改项目', 'collection.create': '初始化项目兼容容器',
'collection.update': '更新项目兼容容器', 'work.create': '上传作品',
};
export default function ManagementPage() {

View File

@@ -1,19 +1,38 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, ArrowUp, ImagePlus, X } from 'lucide-react';
import { ArrowLeft, GripVertical, ImagePlus } from 'lucide-react';
import type { NoteDetail } from '@shared/types';
import { api } from '@/api/client';
type Item = { file: File; url: string };
type ImageItem = { file: File; url: string };
export default function NewVersionPage() {
const id = Number(useParams().noteId); const navigate = useNavigate();
const [work,setWork]=useState<NoteDetail|null>(null); const [title,setTitle]=useState(''); const [description,setDescription]=useState(''); const [tags,setTags]=useState(''); const [items,setItems]=useState<Item[]>([]); const [busy,setBusy]=useState(false); const [error,setError]=useState('');
useEffect(()=>{void api.getNote(id).then((item)=>{setWork(item);setTitle(item.title);setDescription(item.description);setTags(item.tags.join(', '))})},[id]);
useEffect(()=>()=>items.forEach((item)=>URL.revokeObjectURL(item.url)),[items]);
const add=(files:FileList|null)=>{if(!files)return;setItems((current)=>[...current,...Array.from(files).slice(0,30-current.length).map((file)=>({file,url:URL.createObjectURL(file)}))])};
const move=(index:number,direction:-1|1)=>setItems((current)=>{const target=index+direction;if(target<0||target>=current.length)return current;const copy=[...current];[copy[index],copy[target]]=[copy[target],copy[index]];return copy});
const submit=async()=>{if(!title.trim()||!items.length)return;setBusy(true);setError('');try{await api.createWorkVersion(id,{title:title.trim(),description,tags:tags?[tags]:[],images:items.map((item)=>item.file)});navigate(`/works/${id}`)}catch(reason){setError(reason instanceof Error?reason.message:'新版本上传失败');setBusy(false)}};
if(!work)return null;
return <main className="mx-auto max-w-6xl px-5 py-9 lg:px-10 lg:py-12"><Link to={`/works/${id}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/></Link><div className="mt-8 grid gap-10 lg:grid-cols-[.75fr_1.25fr]"><section><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">New revision / V{work.version_number+1}</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em]"></h1><p className="mt-4 text-sm leading-6 text-black/45"></p><label className="mt-8 block text-xs text-black/50"><input value={title} onChange={(e)=>setTitle(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 bg-white p-3.5 text-sm"/></label><label className="mt-5 block text-xs text-black/50"><textarea rows={7} value={description} onChange={(e)=>setDescription(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 bg-white p-3.5 text-sm"/></label><label className="mt-5 block text-xs text-black/50">Tag<input value={tags} onChange={(e)=>setTags(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 bg-white p-3.5 text-sm"/></label>{error&&<p className="mt-4 rounded-xl bg-red-50 p-3 text-xs text-red-700">{error}</p>}<button disabled={busy||!title.trim()||!items.length} onClick={()=>void submit()} className="mt-7 w-full rounded-full bg-black py-4 text-sm text-white disabled:opacity-30">{busy?'正在上传…':`创建 V${work.version_number+1}`}</button></section><section><label className="grid min-h-52 cursor-pointer place-items-center rounded-[28px] border border-dashed border-black/20 bg-white text-center"><input type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif" multiple className="hidden" onChange={(e)=>add(e.target.files)}/><span><ImagePlus className="mx-auto text-black/30"/><b className="mt-3 block text-sm"></b><small className="mt-1 block text-black/35">130 </small></span></label><div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3">{items.map((item,index)=><div key={item.url} className="group relative overflow-hidden rounded-2xl border border-black/10 bg-white"><img src={item.url} alt="" className="aspect-[4/5] w-full object-cover"/><div className="absolute inset-x-2 bottom-2 flex justify-between"><button onClick={()=>move(index,-1)} className="rounded-full bg-white/90 p-2 disabled:opacity-30" disabled={index===0}><ArrowUp size={13}/></button><button onClick={()=>setItems((current)=>current.filter((_,i)=>i!==index))} className="rounded-full bg-white/90 p-2"><X size={13}/></button></div>{index===0&&<span className="absolute left-2 top-2 rounded-full bg-black px-2 py-1 text-[9px] text-white"></span>}</div>)}</div></section></div></main>;
const id = Number(useParams().noteId);
const navigate = useNavigate();
const [work, setWork] = useState<NoteDetail | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [tags, setTags] = useState('');
const [images, setImages] = useState<ImageItem[]>([]);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const imagesRef = useRef<ImageItem[]>([]);
useEffect(() => { void api.getWork(id).then((item) => { setWork(item); setTitle(item.title); setDescription(item.description); setTags(item.tags.join(', ')); }).catch((reason) => setError(reason instanceof Error ? reason.message : '作品加载失败')); }, [id]);
useEffect(() => { imagesRef.current = images; }, [images]);
useEffect(() => () => imagesRef.current.forEach((image) => URL.revokeObjectURL(image.url)), []);
const addImages = (files: FileList | null) => { if (!files) return; setImages((current) => [...current, ...Array.from(files).slice(0, 30 - current.length).map((file) => ({ file, url: URL.createObjectURL(file) }))]); };
const removeImage = (index: number) => { URL.revokeObjectURL(images[index].url); setImages((current) => current.filter((_, itemIndex) => itemIndex !== index)); };
const dropImage = (target: number) => { if (dragIndex === null || dragIndex === target) { setDragIndex(null); return; } setImages((current) => { const next = [...current]; const [moved] = next.splice(dragIndex, 1); next.splice(target, 0, moved); return next; }); setDragIndex(null); };
const nextRound = (work?.rounds[0]?.round_number ?? 0) + 1;
const valid = Boolean(title.trim() && images.length > 0 && !busy);
const submit = async () => { if (!valid) return; setBusy(true); setError(''); try { await api.createRound(id, { title: title.trim(), description, tags: tags.trim() ? [tags.trim()] : [], images: images.map((image) => image.file) }); navigate(`/works/${id}`); } catch (reason) { setError(reason instanceof Error ? reason.message : '验收轮次提交失败'); setBusy(false); } };
if (!work) return <main className="grid min-h-[60vh] place-items-center px-5 text-sm text-black/45">{error || '正在加载作品…'}</main>;
if (work.project.status !== 'active') return <main className="grid min-h-[60vh] place-items-center px-5 text-center text-sm text-black/45"><div><p></p><Link to={`/works/${id}`} className="mt-4 inline-flex items-center gap-2 text-black"><ArrowLeft size={14}/></Link></div></main>;
return <main className="mx-auto max-w-6xl px-5 py-9 lg:px-10 lg:py-12"><Link to={`/works/${id}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/></Link><header className="mt-8 border-b border-black/10 pb-8"><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Review round {nextRound}</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em] md:text-6xl"> {nextRound} </h1><p className="mt-4 max-w-xl text-sm leading-7 text-black/45"></p></header><div className="mt-8 grid gap-8 lg:grid-cols-[.8fr_1.2fr]"><section className="space-y-5"><Field label="作品标题"><input value={title} onChange={(event) => setTitle(event.target.value)}/></Field><Field label="正文"><textarea rows={7} value={description} onChange={(event) => setDescription(event.target.value)}/></Field><Field label="Tag"><input value={tags} onChange={(event) => setTags(event.target.value)}/></Field></section><section><label className="grid min-h-44 cursor-pointer place-items-center rounded-[26px] border border-dashed border-black/20 bg-white text-center"><input type="file" accept="image/jpeg,image/png,image/webp,image/avif" multiple className="hidden" onChange={(event) => addImages(event.target.files)}/><span><ImagePlus className="mx-auto text-black/30"/><b className="mt-2 block text-sm"></b><small className="mt-1 block text-black/35"> 30 </small></span></label><div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3">{images.map((image, index) => <div key={image.url} data-image-index={index} draggable onDragStart={() => setDragIndex(index)} onDragOver={(event) => event.preventDefault()} onDrop={() => dropImage(index)} onPointerDown={(event) => { if (event.pointerType !== 'mouse') { setDragIndex(index); event.currentTarget.setPointerCapture(event.pointerId); } }} onPointerUp={(event) => { if (event.pointerType === 'mouse') return; const target = document.elementFromPoint(event.clientX, event.clientY)?.closest<HTMLElement>('[data-image-index]'); dropImage(Number(target?.dataset.imageIndex ?? index)); }} className="group"><div className="relative cursor-grab overflow-hidden rounded-2xl bg-[#eeece6]"><img src={image.url} alt="" className="aspect-[4/5] w-full object-cover" draggable={false}/><span className="absolute left-2 top-2 grid h-6 min-w-6 place-items-center rounded-full bg-black/70 px-1.5 text-[9px] text-white">{index + 1}</span><GripVertical className="absolute bottom-2 right-2 text-white" size={16}/></div><button onClick={() => removeImage(index)} className="mt-2 w-full text-center text-[10px] text-black/35 hover:text-red-600"></button></div>)}</div>{error && <p className="mt-5 rounded-xl bg-red-50 p-3 text-xs text-red-700">{error}</p>}<button disabled={!valid} onClick={() => void submit()} className="mt-7 w-full rounded-full bg-black py-4 text-sm text-white disabled:opacity-30">{busy ? '正在提交…' : `提交第 ${nextRound}`}</button></section></div></main>;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <label className="block text-xs text-black/50">{label}<div className="mt-2 [&_input]:w-full [&_input]:rounded-xl [&_input]:border [&_input]:border-black/10 [&_input]:bg-white [&_input]:p-3.5 [&_textarea]:w-full [&_textarea]:rounded-xl [&_textarea]:border [&_textarea]:border-black/10 [&_textarea]:bg-white [&_textarea]:p-3.5">{children}</div></label>; }

View File

@@ -1,65 +1,51 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, History, MessageSquareText, RotateCcw, Send, UploadCloud } from 'lucide-react';
import { ArrowLeft, RotateCcw, UploadCloud } from 'lucide-react';
import type { NoteDetail } from '@shared/types';
import { api } from '@/api/client';
import AnnotatableImage from '@/components/AnnotatableImage';
import AnnotatableText from '@/components/AnnotatableText';
import AnnotatableText, { type TextSelectionDraft } from '@/components/AnnotatableText';
import CollaborationDrawer, { type DrawerTextDraft } from '@/components/CollaborationDrawer';
import ImageReviewModal from '@/components/ImageReviewModal';
import StatusBadge from '@/components/StatusBadge';
import { useAuthStore } from '@/store/useAuthStore';
export default function NoteDetailPage() {
const id = Number(useParams().noteId);
const [search] = useSearchParams();
const selected = search.get('version');
const version = selected ? Number(selected) : undefined;
const requestedRound = search.get('round') ? Number(search.get('round')) : undefined;
const user = useAuthStore((state) => state.user);
const [work, setWork] = useState<NoteDetail | null>(null);
const [comment, setComment] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const load = useCallback(async () => {
const result = await api.getNote(id, version);
setWork({ ...result, text_annotations: result.text_annotations ?? [] });
}, [id, version]);
const [selectedImage, setSelectedImage] = useState<{ imageId: number } | null>(null);
const [focusImageId, setFocusImageId] = useState<number>();
const [textDraft, setTextDraft] = useState<(DrawerTextDraft & { target: 'title' | 'description' | 'tags' }) | null>(null);
const [focusFeedback, setFocusFeedback] = useState<{ type: 'text_annotation' | 'image_annotation'; id: number } | null>(null);
const load = useCallback(async () => setWork(await api.getWork(id, requestedRound)), [id, requestedRound]);
useEffect(() => { void load(); }, [load]);
if (!work) return <div className="p-20 text-center text-black/35"></div>;
const latestVersion = Math.max(...work.versions.map((item) => item.version_number));
const viewingLatest = work.version_number === latestVersion;
const canReopen = viewingLatest && work.review_status === 'approved' && (user?.role === 'group_admin' || user?.role === 'platform_admin');
if (!work) return <main className="grid min-h-[60vh] place-items-center text-sm text-black/35"></main>;
const viewed = work.rounds.find((round) => round.version_number === work.version_number);
const current = Boolean(viewed && Number(viewed.review_round_id) === Number(work.active_round_id));
const readOnly = work.project.status !== 'active' || work.project.review_status === 'completed' || !current || viewed?.round_status !== 'reviewing';
const canReopen = current && work.review_status === 'approved' && (user?.role === 'group_admin' || user?.role === 'platform_admin');
const roundNumber = viewed?.round_number ?? 1;
const tagsText = work.tags.join(' ');
const selectText = (target: 'title' | 'description' | 'tags', label: string, selection: TextSelectionDraft) => { setTextDraft({ target, label, ...selection }); setFocusImageId(undefined); setFocusFeedback(null); setDrawerOpen(true); };
const openTextAnnotation = (annotationId: number) => { setTextDraft(null); setFocusImageId(undefined); setFocusFeedback({ type: 'text_annotation', id: annotationId }); setDrawerOpen(true); };
const openImage = (imageId: number) => { setSelectedImage({ imageId }); setFocusImageId(imageId); setTextDraft(null); setFocusFeedback(null); };
const openImageAnnotation = (imageId: number, annotationId: number) => { setFocusImageId(imageId); setTextDraft(null); setFocusFeedback({ type: 'image_annotation', id: annotationId }); setDrawerOpen(true); };
const reopen = async () => { if (!reason.trim()) return; setBusy(true); setMessage(''); try { await api.reopenWork(id, reason.trim()); setReason(''); await load(); } catch (error) { setMessage(error instanceof Error ? error.message : '操作失败'); } finally { setBusy(false); } };
const drawerFooter = canReopen ? <div className="mt-3 rounded-2xl border border-black/10 bg-white p-3"><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} className="w-full resize-none text-xs outline-none" placeholder="填写重新打开验收的原因"/><button disabled={busy || !reason.trim()} onClick={() => void reopen()} className="mt-2 flex w-full items-center justify-center gap-2 rounded-full border border-black/15 py-2.5 text-xs disabled:opacity-30"><RotateCcw size={13}/></button>{message && <p className="mt-2 text-xs text-red-600">{message}</p>}</div> : null;
const send = async () => {
if (!comment.trim()) return;
setBusy(true);
await api.addComment(id, { content: comment.trim(), author_name: user?.display_name || '工作台', author_role: 'operator' });
setComment(''); await load(); setBusy(false);
};
const reopen = async () => {
if (!reason.trim()) return;
setBusy(true); setMessage('');
try { await api.reopenWork(id, reason.trim()); setReason(''); await load(); }
catch (error) { setMessage(error instanceof Error ? error.message : '操作失败'); }
finally { setBusy(false); }
};
return <main>
<header className="border-b border-black/10 bg-white"><div className="mx-auto max-w-[1500px] px-5 py-5 lg:px-10"><div className="flex flex-wrap items-center justify-between gap-3">
<Link to={`/projects/${work.project.id}/collections/${work.collection.id}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/>{work.collection.name}</Link>
<div className="flex flex-wrap items-center gap-2"><StatusBadge status={work.review_status}/>{work.versions.map((item) => <Link key={item.version_number} to={`/works/${id}?version=${item.version_number}`} className={`rounded-full px-3 py-1.5 text-[11px] ${item.version_number === work.version_number ? 'bg-black text-white' : 'bg-black/5'}`}>V{item.version_number}</Link>)}{viewingLatest && <Link to={`/works/${id}/new-version`} className="inline-flex items-center gap-2 rounded-full bg-black px-4 py-2 text-xs text-white"><UploadCloud size={13}/></Link>}</div>
</div></div></header>
<div className="mx-auto grid max-w-[1500px] lg:grid-cols-[minmax(0,1fr)_360px]">
<article className="min-w-0 px-5 py-10 lg:px-10 lg:py-14"><div className="mx-auto max-w-5xl">
<p className="mb-5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs font-medium text-black/50"><span>{work.project.name}</span><span className="text-black/20">/</span><span>{work.collection.name}</span><span className="text-black/20">/</span><span className="font-mono uppercase tracking-[.16em] text-[#ba623c]">Work {String(work.id).padStart(3,'0')}</span></p>
<div className="columns-1 gap-5 xl:columns-2">{work.images.map((image) => <AnnotatableImage key={image.id} image={image} annotations={image.annotations} onAdd={async (x,y,text) => { await api.addAnnotation(image.id, { x,y,content:text }); await load(); }}/>)}</div>
<div className="mt-12 border-t border-black/10 pt-10"><AnnotatableText label="标题" annotations={work.text_annotations.filter((annotation) => annotation.target === 'title')} onAdd={async (content) => { await api.addTextAnnotation(id, { version_number: work.version_number, target: 'title', content }); await load(); }}><h1 className="max-w-4xl font-display text-4xl leading-[1.08] tracking-[-.04em] md:text-5xl">{work.title}</h1></AnnotatableText>{work.description && <div className="mt-7"><AnnotatableText label="正文" annotations={work.text_annotations.filter((annotation) => annotation.target === 'description')} onAdd={async (content) => { await api.addTextAnnotation(id, { version_number: work.version_number, target: 'description', content }); await load(); }}><p className="max-w-3xl whitespace-pre-wrap text-[15px] leading-8 text-black/65">{work.description}</p></AnnotatableText></div>}{work.tags.length > 0 && <p className="mt-7 max-w-3xl whitespace-pre-wrap text-[15px] leading-8 text-black/65">{work.tags.join(' ')}</p>}</div>
</div></article>
<aside className="border-t border-black/10 bg-[#efede7] lg:sticky lg:top-[66px] lg:h-[calc(100vh-66px)] lg:border-l lg:border-t-0"><div className="flex h-full flex-col">
<div className="border-b border-black/10 p-5"><p className="font-mono text-[10px] uppercase tracking-[.25em] text-black/35">Review conversation</p><h2 className="mt-2 font-display text-3xl"></h2><p className="mt-2 text-xs leading-5 text-black/45"></p></div>
<div className="flex-1 space-y-3 overflow-auto p-4">{work.comments.length === 0 && <div className="py-10 text-center text-xs text-black/35"><MessageSquareText className="mx-auto mb-3"/></div>}{work.comments.map((item) => <div key={item.id} className={`rounded-2xl p-3 text-sm ${item.author_role === 'operator' ? 'ml-5 bg-black text-white' : 'mr-5 bg-white'}`}><div className="mb-2 flex items-center justify-between text-[10px] opacity-50"><span>{item.author_name}</span><span>{new Date(item.created_at).toLocaleString('zh-CN')}</span></div><p className="whitespace-pre-wrap leading-6">{item.content}</p></div>)}{work.review_events.length > 0 && <div className="mt-5 border-t border-black/10 pt-4"><div className="mb-3 flex items-center gap-2 text-xs text-black/45"><History size={13}/></div>{work.review_events.map((event) => <div key={event.id} className="mb-2 rounded-xl bg-white/60 p-3 text-[11px] leading-5 text-black/55">V{event.version_number} · {event.actor_name} · {event.to_status}{event.reason && <p className="mt-1 text-black/70">{event.reason}</p>}</div>)}</div>}</div>
<div className="border-t border-black/10 p-4"><div className="relative"><textarea rows={3} value={comment} onChange={(e) => setComment(e.target.value)} className="w-full resize-none rounded-2xl border border-black/10 bg-white p-3 pr-12 text-sm" placeholder="回复客户或记录处理结果…"/><button disabled={busy || !comment.trim()} onClick={() => void send()} className="absolute bottom-3 right-3 rounded-full bg-black p-2 text-white disabled:opacity-30"><Send size={14}/></button></div>{canReopen && <div className="mt-3 rounded-2xl border border-black/10 bg-white p-3"><textarea rows={2} value={reason} onChange={(e) => setReason(e.target.value)} className="w-full resize-none text-xs outline-none" placeholder="填写重新打开验收的原因"/><button disabled={busy || !reason.trim()} onClick={() => void reopen()} className="mt-2 flex w-full items-center justify-center gap-2 rounded-full border border-black/15 py-2.5 text-xs disabled:opacity-30"><RotateCcw size={13}/></button></div>}{message && <p className="mt-2 text-xs text-red-600">{message}</p>}</div>
</div></aside>
</div>
return <main onClick={() => { if (drawerOpen) setDrawerOpen(false); }}><header className="border-b border-black/10 bg-white"><div className="mx-auto max-w-[1500px] px-5 py-5 lg:px-10"><div className="flex flex-wrap items-center justify-between gap-3"><Link to={`/projects/${work.project.id}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/>{work.project.name}</Link><div className="flex flex-wrap items-center gap-2"><StatusBadge status={work.review_status}/>{work.rounds.map((round) => <Link key={round.round_number} to={`/works/${id}?round=${round.round_number}`} className={`rounded-full px-3 py-1.5 text-[11px] ${round.round_number === roundNumber ? 'bg-black text-white' : 'bg-black/5 text-black/55'}`}> {round.round_number} </Link>)}{current && work.project.status === 'active' && <Link to={`/works/${id}/new-version`} className="inline-flex items-center gap-2 rounded-full bg-black px-4 py-2 text-xs text-white"><UploadCloud size={13}/></Link>}</div></div></div></header>
<article className="mx-auto max-w-[1280px] px-5 py-10 lg:px-10 lg:py-14"><p className="mb-5 flex flex-wrap items-center gap-2 text-xs text-black/50"><span>{work.project.name}</span><span className="text-black/20">/</span><b className="font-mono uppercase tracking-[.16em] text-[#ba623c]">Work {String(work.id).padStart(3, '0')}</b><span className="text-black/20">/</span><span> {roundNumber} </span></p>{!current && <div className="mb-6 rounded-2xl border border-black/10 bg-black/[.03] px-5 py-4 text-sm text-black/55"></div>}
<div className="columns-1 gap-5 xl:columns-2">{work.images.map((image) => <AnnotatableImage key={image.id} image={image} annotations={image.annotations} readOnly onAdd={async () => undefined} onOpen={() => openImage(image.id)} onAnnotationOpen={(annotationId) => openImageAnnotation(image.id, annotationId)}/>)}</div>
<div className="mt-12 border-t border-black/10 pt-10"><AnnotatableText text={work.title} label="标题" variant="title" annotations={work.text_annotations.filter((item) => item.target === 'title')} readOnly={readOnly} onSelect={(selection) => selectText('title', '标题', selection)} onOpenAnnotation={openTextAnnotation}/>{work.description && <div className="mt-9"><AnnotatableText text={work.description} label="正文" annotations={work.text_annotations.filter((item) => item.target === 'description')} readOnly={readOnly} onSelect={(selection) => selectText('description', '正文', selection)} onOpenAnnotation={openTextAnnotation}/></div>}{tagsText && <div className="mt-8"><AnnotatableText text={tagsText} label="Tag" annotations={work.text_annotations.filter((item) => item.target === 'tags')} readOnly={readOnly} onSelect={(selection) => selectText('tags', 'Tag', selection)} onOpenAnnotation={openTextAnnotation}/></div>}</div>
</article><CollaborationDrawer work={work} open={drawerOpen} setOpen={setDrawerOpen} readOnly={readOnly} actorName={user?.display_name} actorRole="operator" focusImageId={focusImageId} textDraft={textDraft} focusFeedback={focusFeedback} onCancelTextAnnotation={() => setTextDraft(null)} onTextAnnotation={async (content) => { if (!textDraft) return; await api.addTextSelectionAnnotation(id, { round_number: roundNumber, target: textDraft.target, start_offset: textDraft.start, end_offset: textDraft.end, selected_text: textDraft.text, content }); setTextDraft(null); await load(); }} onComment={async (content) => { await api.addComment(id, { content, author_name: user?.display_name || '工作台', author_role: 'operator' }); await load(); }} onReply={async (type, feedbackId, content) => { await api.replyToFeedback(id, type, feedbackId, content); await load(); }} onWithdraw={async (type, feedbackId) => { await api.withdrawFeedback(id, type, feedbackId); await load(); }} footer={drawerFooter}/>
{selectedImage && <ImageReviewModal work={work} initialImageId={selectedImage.imageId} readOnly={readOnly} onClose={() => { setSelectedImage(null); setDrawerOpen(false); }} onReload={load} onImageChange={(imageId) => { setSelectedImage({ imageId }); setFocusImageId(imageId); setFocusFeedback(null); }} onOpenAnnotation={openImageAnnotation}/>}
</main>;
}

View File

@@ -1,18 +1,20 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowLeft, ArrowRight, CalendarRange, Copy, KeyRound, Pencil, Plus, X } from 'lucide-react';
import type { Project, WorkCollection } from '@shared/types';
import { ArrowLeft, Copy, ImageIcon, KeyRound, MessageCircle, Pencil, Plus, Search, X } from 'lucide-react';
import type { Note, Project, ReviewStatus } from '@shared/types';
import { api } from '@/api/client';
import StatusBadge from '@/components/StatusBadge';
const projectStatus = { draft: '待提交', reviewing: '验收中', completed: '验收完毕', archived: '已归档' } as const;
export default function ProjectPage() {
const id = Number(useParams().projectId);
const [project, setProject] = useState<Project | null>(null);
const [collections, setCollections] = useState<WorkCollection[]>([]);
const [collectionOpen, setCollectionOpen] = useState(false);
const [works, setWorks] = useState<Note[]>([]);
const [q, setQ] = useState('');
const [status, setStatus] = useState<ReviewStatus | ''>('');
const [editOpen, setEditOpen] = useState(false);
const [accessOpen, setAccessOpen] = useState(false);
const [name, setName] = useState('');
const [desc, setDesc] = useState('');
const [projectName, setProjectName] = useState('');
const [projectDesc, setProjectDesc] = useState('');
const [accessEnabled, setAccessEnabled] = useState(false);
@@ -21,44 +23,30 @@ export default function ProjectPage() {
const [message, setMessage] = useState('');
const load = useCallback(async () => {
const [nextProject, nextCollections] = await Promise.all([api.getProject(id), api.listCollections(id)]);
setProject(nextProject);
setCollections(nextCollections);
const [nextProject, nextWorks] = await Promise.all([api.getProject(id), api.listProjectWorks(id)]);
setProject(nextProject); setWorks(nextWorks);
}, [id]);
useEffect(() => { void load(); }, [load]);
if (!project) return null;
const filtered = useMemo(() => works.filter((work) => (!q || work.title.toLowerCase().includes(q.toLowerCase())) && (!status || work.review_status === status)), [works, q, status]);
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 createCollection = async () => {
await api.createCollection(id, { name, client_description: desc });
setCollectionOpen(false); setName(''); setDesc(''); await load();
};
const editProject = async () => {
setProject(await api.updateProject(id, { name: projectName, client_description: projectDesc }));
setEditOpen(false);
};
const openAccess = () => {
setAccessEnabled(Boolean(project.customer_access_enabled));
setAccessPassword('');
setExpiresAt(project.access_expires_at?.slice(0, 16) || '');
setMessage(''); setAccessOpen(true);
};
const saveAccess = async () => {
try {
const updated = await api.updateCustomerAccess(id, { enabled: accessEnabled, password: accessPassword || undefined, expires_at: expiresAt || null });
setProject(updated); setMessage('客户访问设置已保存'); setAccessPassword('');
} catch (reason) { setMessage(reason instanceof Error ? reason.message : '保存失败'); }
};
const openAccess = () => { setAccessEnabled(Boolean(project.customer_access_enabled)); setAccessPassword(''); setExpiresAt(project.access_expires_at?.slice(0, 16) || ''); setMessage(''); 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-20"><Link to="/" className="inline-flex items-center gap-2 text-xs text-white/55"><ArrowLeft size={14}/></Link><div className="mt-10 grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end"><div><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ff795f]">Client Project / {project.slug}</p><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="flex gap-8"><Metric n={project.collection_count} label="作品交付集"/><Metric n={project.work_count} label="作品"/></div><div className="mt-6 flex flex-wrap gap-2"><button onClick={()=>{setProjectName(project.name);setProjectDesc(project.client_description);setEditOpen(true)}} className="inline-flex items-center gap-2 rounded-full border border-white/20 px-4 py-2 text-xs text-white/70 hover:border-white/50 hover:text-white"><Pencil size={13}/></button><button onClick={openAccess} className="inline-flex items-center gap-2 rounded-full border border-white/20 px-4 py-2 text-xs text-white/70 hover:border-white/50 hover:text-white"><KeyRound size={13}/>访</button></div></div></div></div></section>
<section className="mx-auto max-w-[1500px] px-5 py-10 lg:px-10"><div className="mb-6 flex items-center justify-between"><div><p className="font-mono text-[10px] tracking-[.25em] text-black/35">COLLECTIONS</p><h2 className="mt-1 font-display text-3xl"></h2></div><button onClick={()=>setCollectionOpen(true)} className="inline-flex items-center gap-2 rounded-full border border-black/15 bg-white px-5 py-3 text-sm"><Plus size={16}/></button></div><div className="space-y-3">{collections.map((item,index)=><Link key={item.id} to={`/projects/${id}/collections/${item.id}`} className="group grid gap-5 rounded-2xl border border-black/10 bg-white p-5 transition hover:border-black/30 md:grid-cols-[56px_1fr_auto] md:items-center"><div className="grid h-14 w-14 place-items-center rounded-xl bg-[#f1efe9] font-display text-xl">{String(index+1).padStart(2,'0')}</div><div><div className="flex flex-wrap items-center gap-3"><h3 className="font-display text-2xl">{item.name}</h3><span className="rounded-full bg-emerald-50 px-2 py-1 text-[10px] text-emerald-700"></span></div><p className="mt-1 text-sm text-black/45">{item.client_description || '暂无说明'}</p></div><div className="flex items-center gap-6"><div className="text-right text-xs text-black/45"><b className="block text-lg text-black">{item.approved_count}/{item.work_count}</b></div><ArrowRight className="text-black/25 transition group-hover:translate-x-1 group-hover:text-black"/></div></Link>)}</div></section>
{collectionOpen&&<Modal close={()=>setCollectionOpen(false)} icon={<CalendarRange/>} title="新建作品交付集"><input className="mt-7 w-full rounded-xl border border-black/10 p-3" placeholder="例如2026 年 8 月任务" value={name} onChange={(e)=>setName(e.target.value)}/><textarea className="mt-3 w-full rounded-xl border border-black/10 p-3" rows={3} placeholder="客户可见的作品交付集说明" value={desc} onChange={(e)=>setDesc(e.target.value)}/><button disabled={!name} onClick={()=>void createCollection()} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
{editOpen&&<Modal close={()=>setEditOpen(false)} icon={<Pencil/>} title="编辑项目"><label className="mt-7 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={(e)=>setProjectName(e.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={(e)=>setProjectDesc(e.target.value)}/></label><p className="mt-3 text-[11px] text-black/35"> {project.slug} </p><button disabled={!projectName.trim()} onClick={()=>void editProject()} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
{accessOpen&&<Modal close={()=>setAccessOpen(false)} icon={<KeyRound/>} title="客户访问"><div className="mt-7 rounded-2xl border border-black/10 bg-white p-4"><p className="break-all text-xs leading-5 text-black/50">{reviewUrl}</p><button onClick={()=>void navigator.clipboard.writeText(reviewUrl).then(()=>setMessage('链接已复制'))} className="mt-3 inline-flex items-center gap-2 text-xs text-[#aa4f2e]"><Copy size={13}/></button></div><label className="mt-5 flex items-center justify-between rounded-xl border border-black/10 bg-white p-4 text-sm">访<input type="checkbox" checked={accessEnabled} onChange={(e)=>setAccessEnabled(e.target.checked)} className="h-4 w-4 accent-black"/></label><label className="mt-4 block text-xs text-black/45">{project.has_access_password?'重置访问密码(不修改可留空)':'设置访问密码'}<input type="password" value={accessPassword} onChange={(e)=>setAccessPassword(e.target.value)} placeholder="至少 6 位" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm"/></label><label className="mt-4 block text-xs text-black/45"><input type="datetime-local" value={expiresAt} onChange={(e)=>setExpiresAt(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm"/></label>{message&&<p className="mt-3 text-xs text-black/50">{message}</p>}<button onClick={()=>void saveAccess()} className="mt-5 w-full rounded-full bg-black py-3 text-white">访</button></Modal>}
<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="mx-auto max-w-[1500px] px-5 pb-16 lg:px-10"><div className="sticky top-[72px] z-30 -mx-5 flex flex-col gap-3 border-b border-black/10 bg-[#f7f6f2]/92 px-5 py-5 backdrop-blur-xl md:flex-row md:items-center md:justify-between lg:-mx-10 lg:px-10"><div className="flex gap-2 overflow-auto">{([['','全部作品'],['pending','待验收'],['changes_requested','需修改'],['approved','已通过']] as [ReviewStatus|'',string][]).map(([value,label]) => <button key={label} onClick={() => setStatus(value)} className={`whitespace-nowrap rounded-full px-4 py-2 text-xs ${status === value ? 'bg-black text-white' : 'border border-black/10 bg-white text-black/55'}`}>{label}</button>)}</div><div className="flex gap-2"><label className="relative"><Search className="absolute left-3 top-2.5 text-black/35" size={15}/><input value={q} onChange={(event) => setQ(event.target.value)} className="w-full rounded-full border border-black/10 bg-white py-2 pl-9 pr-4 text-sm md:w-64" placeholder="搜索作品标题"/></label>{project.status === 'active' && <Link to={`/projects/${id}/upload`} className="inline-flex items-center gap-2 whitespace-nowrap rounded-full bg-[#ef4b2f] px-5 py-2 text-xs text-white"><Plus size={14}/></Link>}</div></div>
{project.review_status === 'completed' && <div className="mt-7 rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-800"></div>}
<div className="mt-8 grid grid-cols-2 gap-x-4 gap-y-9 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">{filtered.map((work) => <Link to={`/works/${work.id}`} key={work.id} className="group"><article><div className="relative aspect-[4/5] overflow-hidden rounded-[22px] bg-[#ebe9e3]">{work.cover_image ? <img src={work.cover_image} alt={work.title} className="h-full w-full object-cover transition duration-700 group-hover:scale-105"/> : <div className="grid h-full place-items-center text-black/20"><ImageIcon/></div>}<div className="absolute left-2 top-2"><StatusBadge status={work.review_status}/></div>{work.image_count > 1 && <span className="absolute right-2 top-2 rounded-full bg-black/65 px-2 py-1 text-[10px] text-white">{work.image_count} </span>}</div><h2 className="mt-3 line-clamp-2 font-display text-[22px] leading-6">{work.title}</h2><div className="mt-2 flex items-center gap-3 text-[11px] text-black/40"><span className="flex items-center gap-1"><MessageCircle size={12}/>{work.annotation_count + work.comment_count}</span><span> {work.version_number} </span></div></article></Link>)}</div>
{!filtered.length && <div className="py-24 text-center text-sm text-black/35">{works.length ? '没有符合筛选条件的作品' : '项目还没有作品,上传第一件作品开始验收。'}</div>}
</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>}
</main>;
}
function Metric({n,label}:{n:number;label:string}) { return <div><strong className="font-display text-4xl">{String(n).padStart(2,'0')}</strong><span className="ml-2 text-xs text-white/40">{label}</span></div> }
function Modal({close,icon,title,children}:{close:()=>void;icon:React.ReactNode;title:string;children:React.ReactNode}) { return <div className="fixed inset-0 z-[80] grid place-items-center bg-black/45 p-4"><div className="w-full max-w-md rounded-3xl bg-[#f7f6f2] p-7"><div className="flex items-center justify-between"><div>{icon}<h3 className="mt-3 font-display text-3xl">{title}</h3></div><button onClick={close}><X/></button></div>{children}</div></div> }
function Metric({ n, label }: { n: number; label: string }) { return <div className="text-right"><b className="font-display text-3xl">{n}</b><span className="mt-1 block text-[10px] text-white/40">{label}</span></div>; }
function Modal({ title, close, children }: { title: string; close: () => void; children: React.ReactNode }) { return <div className="fixed inset-0 z-[90] grid place-items-center bg-black/50 p-4"><div className="w-full max-w-md rounded-[28px] bg-[#f7f6f2] p-7"><div className="flex items-center justify-between"><h3 className="font-display text-3xl">{title}</h3><button onClick={close}><X/></button></div><div className="mt-6">{children}</div></div></div>; }

View File

@@ -5,13 +5,13 @@ import { api } from '@/api/client';
type Item={file:File;url:string};
export default function UploadPage(){
const {projectId,collectionId}=useParams(); const pid=Number(projectId),cid=Number(collectionId); const nav=useNavigate(); const input=useRef<HTMLInputElement>(null);const draggedUrl=useRef<string|null>(null); const [title,setTitle]=useState('');const [content,setContent]=useState('');const [tags,setTags]=useState('');const [items,setItems]=useState<Item[]>([]);const [dragging,setDragging]=useState<string|null>(null);const [busy,setBusy]=useState(false);const [error,setError]=useState('');
const {projectId}=useParams(); const pid=Number(projectId); const nav=useNavigate(); const input=useRef<HTMLInputElement>(null);const draggedUrl=useRef<string|null>(null); const [title,setTitle]=useState('');const [content,setContent]=useState('');const [tags,setTags]=useState('');const [items,setItems]=useState<Item[]>([]);const [dragging,setDragging]=useState<string|null>(null);const [busy,setBusy]=useState(false);const [error,setError]=useState('');
const can=useMemo(()=>title.trim()&&items.length>0&&!busy,[title,items,busy]); const add=(files:FileList|null)=>{if(!files)return;setItems(p=>[...p,...Array.from(files).filter(f=>f.type.startsWith('image/')).slice(0,30-p.length).map(file=>({file,url:URL.createObjectURL(file)}))])};
const startReorder=(event:React.PointerEvent<HTMLDivElement>,url:string)=>{event.preventDefault();event.currentTarget.setPointerCapture(event.pointerId);draggedUrl.current=url;setDragging(url)};
const moveReorder=(event:React.PointerEvent<HTMLDivElement>)=>{const source=draggedUrl.current;if(!source)return;const target=document.elementFromPoint(event.clientX,event.clientY)?.closest<HTMLElement>('[data-image-key]')?.dataset.imageKey;if(!target||target===source)return;setItems(current=>{const from=current.findIndex(item=>item.url===source),to=current.findIndex(item=>item.url===target);if(from<0||to<0||from===to)return current;const next=[...current];const [moved]=next.splice(from,1);next.splice(to,0,moved);return next})};
const finishReorder=(event:React.PointerEvent<HTMLDivElement>)=>{if(event.currentTarget.hasPointerCapture(event.pointerId))event.currentTarget.releasePointerCapture(event.pointerId);draggedUrl.current=null;setDragging(null)};
const submit=async()=>{if(!can)return;setBusy(true);setError('');try{const work=await api.createNote({collectionId:cid,title:title.trim(),description:content.trim(),tags:tags?[tags]:[],images:items.map(x=>x.file)});items.forEach(x=>URL.revokeObjectURL(x.url));nav(`/works/${work.id}`)}catch(e){setError(e instanceof Error?e.message:'上传失败')}finally{setBusy(false)}};
return <main className="mx-auto max-w-6xl px-5 py-8 lg:px-10 lg:py-12"><Link to={`/projects/${pid}/collections/${cid}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/></Link><div className="mt-8 grid gap-10 lg:grid-cols-[.8fr_1.2fr]"><section><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ef4b2f]">New work</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em]"></h1><div className="mt-8 space-y-6"><Field label="作品标题 *"><input value={title} onChange={e=>setTitle(e.target.value)} placeholder="一句清晰的作品标题"/></Field><Field label="正文"><textarea rows={7} value={content} onChange={e=>setContent(e.target.value)} placeholder="输入作品正文,支持换行与 Emoji"/></Field><Field label="Tag"><input value={tags} onChange={e=>setTags(e.target.value)} placeholder="品牌, 七月内容, 待发布"/><p className="mt-2 text-[11px] text-black/35">使</p></Field></div></section>
const submit=async()=>{if(!can)return;setBusy(true);setError('');try{const work=await api.createWork(pid,{title:title.trim(),description:content.trim(),tags:tags?[tags]:[],images:items.map(x=>x.file)});items.forEach(x=>URL.revokeObjectURL(x.url));nav(`/works/${work.id}`)}catch(e){setError(e instanceof Error?e.message:'上传失败')}finally{setBusy(false)}};
return <main className="mx-auto max-w-6xl px-5 py-8 lg:px-10 lg:py-12"><Link to={`/projects/${pid}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/></Link><div className="mt-8 grid gap-10 lg:grid-cols-[.8fr_1.2fr]"><section><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ef4b2f]">New work</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em]"></h1><div className="mt-8 space-y-6"><Field label="作品标题 *"><input value={title} onChange={e=>setTitle(e.target.value)} placeholder="一句清晰的作品标题"/></Field><Field label="正文"><textarea rows={7} value={content} onChange={e=>setContent(e.target.value)} placeholder="输入作品正文,支持换行与 Emoji"/></Field><Field label="Tag"><input value={tags} onChange={e=>setTags(e.target.value)} placeholder="品牌, 七月内容, 待发布"/><p className="mt-2 text-[11px] text-black/35">使</p></Field></div></section>
<section><div onClick={()=>input.current?.click()} onDragOver={e=>e.preventDefault()} onDrop={e=>{e.preventDefault();add(e.dataTransfer.files)}} className="grid min-h-52 cursor-pointer place-items-center rounded-[28px] border border-dashed border-black/20 bg-white p-8 text-center hover:border-black"><div><ImagePlus className="mx-auto"/><h2 className="mt-4 font-display text-2xl"></h2><p className="mt-2 text-xs text-black/40"> 30 · </p></div><input ref={input} type="file" accept="image/*" multiple className="hidden" onChange={e=>add(e.target.files)}/></div>
{items.length>0&&<div className="mt-5 grid grid-cols-3 gap-x-3 gap-y-5 sm:grid-cols-4">{items.map((it,i)=><div key={it.url} data-image-key={it.url}><div role="listitem" aria-label={`${i===0?'封面':`${i+1}`},拖动调整顺序`} onPointerDown={event=>startReorder(event,it.url)} onPointerMove={moveReorder} onPointerUp={finishReorder} onPointerCancel={finishReorder} className={`aspect-square touch-none select-none overflow-hidden rounded-xl bg-black/5 transition duration-200 ${dragging===it.url?'scale-[.97] cursor-grabbing opacity-70 ring-2 ring-[#ef4b2f]':'cursor-grab hover:scale-[.99]'}`}><img src={it.url} draggable={false} className="pointer-events-none h-full w-full object-cover"/></div><div className="mt-2 flex items-center justify-between gap-2 px-0.5"><span className="text-[10px] text-black/35">{i===0?'封面':`${i+1}`}</span><button type="button" onClick={()=>setItems(p=>p.filter((_,j)=>j!==i))} className="inline-flex items-center gap-1 text-[10px] text-black/35 transition hover:text-red-600"><X size={11}/></button></div></div>)}</div>}
{error&&<p className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-600">{error}</p>}<button onClick={submit} disabled={!can} className="mt-7 flex w-full items-center justify-center gap-2 rounded-full bg-[#ef4b2f] py-4 text-sm font-medium text-white disabled:opacity-30">{busy?<><Loader2 className="animate-spin" size={17}/></>:'创建并提交验收'}</button></section></div></main>

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""通过 Delivery Desk API 创建作品或上传作品新版本"""
"""通过 Delivery Desk API 创建作品或提交新的验收轮次"""
from __future__ import annotations
@@ -37,11 +37,10 @@ def request_json(opener, url: str, *, method: str = "GET", data: bytes | None =
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="通过 API 创建测试作品或上传新版本")
parser = argparse.ArgumentParser(description="通过 API 创建测试作品或提交新的验收轮次")
parser.add_argument("--base-url", default=os.getenv("DELIVERY_DESK_BASE_URL", "http://127.0.0.1:3010"))
parser.add_argument("--project-id", type=int, default=None, help="不传时自动选择唯一可访问的项目")
parser.add_argument("--collection-id", type=int, default=None, help="不传时自动选择项目下唯一的作品交付集")
parser.add_argument("--work-id", type=int, default=None, help="传入后为该作品创建新版本")
parser.add_argument("--work-id", type=int, default=None, help="传入后为该作品提交新的验收轮次")
parser.add_argument("--external-id", default=None, help="调用方作品唯一标识,用于幂等创建和找回作品")
parser.add_argument("--api-key", default=os.getenv("DELIVERY_DESK_API_KEY"))
parser.add_argument("--username", default=os.getenv("DELIVERY_DESK_USERNAME", "operator"))
@@ -99,16 +98,6 @@ def main() -> int:
_, projects, _ = request_json(opener, f"{base_url}/api/projects", headers=auth_headers)
project = choose_item(projects, args.project_id, "项目")
project_id = int(project["id"])
_, collections, _ = request_json(
opener,
f"{base_url}/api/projects/{project_id}/collections",
headers=auth_headers,
)
collection = choose_item(collections, args.collection_id, "作品交付集")
collection_id = int(collection["id"])
if int(collection["project_id"]) != project_id:
raise ApiError(f"作品交付集 {collection_id} 不属于项目 {project_id}")
title = args.title or f"API 测试作品 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
image_urls = args.image_urls or ["https://placehold.co/1200x800/png?text=Delivery+Desk+API+Test"]
upload_headers = {**auth_headers, "Content-Type": "application/json"}
@@ -120,28 +109,29 @@ def main() -> int:
}
if args.work_id is not None:
_, current, _ = request_json(opener, f"{base_url}/api/notes/{args.work_id}", headers=auth_headers)
if int(current["project"]["id"]) != project_id or int(current["collection"]["id"]) != collection_id:
raise ApiError(f"作品 {args.work_id} 不属于选定的项目和作品交付集")
_, current, _ = request_json(opener, f"{base_url}/api/works/{args.work_id}", headers=auth_headers)
if int(current["project"]["id"]) != project_id:
raise ApiError(f"作品 {args.work_id} 不属于选定的项目")
status, work, _ = request_json(
opener,
f"{base_url}/api/notes/{args.work_id}/versions",
f"{base_url}/api/works/{args.work_id}/rounds",
method="POST",
data=json.dumps(payload).encode("utf-8"),
headers=upload_headers,
)
if status != 201 or not work or int(work.get("id", 0)) != args.work_id:
raise ApiError("版本接口没有返回目标作品")
action = "version_created"
raise ApiError("验收轮次接口没有返回目标作品")
action = "round_created"
external_id = work.get("external_id")
else:
external_id = args.external_id or f"api-smoke-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
create_payload = {**payload, "collectionId": collection_id, "externalId": external_id}
create_payload = {**payload, "externalId": external_id}
body = json.dumps(create_payload).encode("utf-8")
status, work, _ = request_json(opener, f"{base_url}/api/notes", method="POST", data=body, headers=upload_headers)
if status not in (200, 201) or not work or int(work.get("collection_id", 0)) != collection_id:
raise ApiError("接口未返回属于目标作品交付集的作品")
_, repeated, _ = request_json(opener, f"{base_url}/api/notes", method="POST", data=body, headers=upload_headers)
works_url = f"{base_url}/api/projects/{project_id}/works"
status, work, _ = request_json(opener, works_url, method="POST", data=body, headers=upload_headers)
if status not in (200, 201) or not work or int(work.get("project_id", 0)) != project_id:
raise ApiError("接口未返回属于目标项目的作品")
_, repeated, _ = request_json(opener, works_url, method="POST", data=body, headers=upload_headers)
if int(repeated.get("id", 0)) != int(work["id"]) or not repeated.get("idempotent"):
raise ApiError("相同 externalId 的重复请求未通过幂等校验")
action = "work_created" if status == 201 else "existing_work_returned"
@@ -153,7 +143,6 @@ def main() -> int:
"action": action,
"group": project.get("group_name"),
"project": project.get("name"),
"collection": collection.get("name"),
"work_id": work.get("id"),
"external_id": external_id,
"version_number": work.get("version_number"),

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""获取指定作品验收轮次的全部批注与反馈。"""
from __future__ import annotations
import argparse
import getpass
import json
import os
import sys
from http.cookiejar import CookieJar
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import HTTPCookieProcessor, Request, build_opener
class ApiError(RuntimeError):
"""Delivery Desk API 请求失败。"""
def request_json(opener, url: str, *, method: str = "GET", data: bytes | None = None, headers: dict[str, str] | None = None):
request = Request(url, data=data, method=method, headers=headers or {})
try:
with opener.open(request, timeout=15) as response:
body = response.read().decode("utf-8")
return response.status, json.loads(body) if body else None
except HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
try:
parsed = json.loads(body)
detail = parsed.get("error", body) if isinstance(parsed, dict) else body
except json.JSONDecodeError:
detail = body
raise ApiError(f"{method} {url} 返回 {error.code}: {detail}") from error
except URLError as error:
raise ApiError(f"无法连接 {url}: {error.reason}") from error
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="获取作品指定验收轮次的全部批注")
parser.add_argument("--base-url", default=os.getenv("DELIVERY_DESK_BASE_URL", "http://127.0.0.1:3010"))
parser.add_argument("--project-name", default="光影内容计划", help="用于校验作品所属项目")
parser.add_argument("--work-id", type=int, default=13)
parser.add_argument("--round", type=int, default=2, dest="round_number")
parser.add_argument("--include-history", action="store_true", help="同时返回已关闭或已撤回的历史反馈")
parser.add_argument("--api-key", default=os.getenv("DELIVERY_DESK_API_KEY"))
parser.add_argument("--username", default=os.getenv("DELIVERY_DESK_USERNAME", "operator"))
parser.add_argument("--password", default=os.getenv("DELIVERY_DESK_PASSWORD"))
return parser.parse_args()
def authenticate(opener, base_url: str, args: argparse.Namespace) -> dict[str, str]:
if args.api_key:
return {"Authorization": f"Bearer {args.api_key}"}
password = args.password or getpass.getpass(f"请输入账号 {args.username} 的密码: ")
payload = json.dumps({"username": args.username, "password": password}).encode("utf-8")
status, _ = request_json(
opener,
f"{base_url}/api/auth/login",
method="POST",
data=payload,
headers={"Content-Type": "application/json"},
)
if status != 200:
raise ApiError("登录接口未返回成功状态")
return {}
def main() -> int:
args = parse_args()
if args.work_id < 1 or args.round_number < 1:
raise ApiError("作品 ID 和轮次必须是正整数")
base_url = args.base_url.rstrip("/")
opener = build_opener(HTTPCookieProcessor(CookieJar()))
auth_headers = authenticate(opener, base_url, args)
query = urlencode({"round": args.round_number, "include_history": str(args.include_history).lower()})
_, context = request_json(
opener,
f"{base_url}/api/works/{args.work_id}/optimization-context?{query}",
headers=auth_headers,
)
if not isinstance(context, dict):
raise ApiError("内容优化接口返回格式无效")
project = context.get("project")
actual_project_name = project.get("name") if isinstance(project, dict) else None
if actual_project_name != args.project_name:
raise ApiError(f"Work {args.work_id:03d} 属于项目“{actual_project_name}”,不是“{args.project_name}")
print(json.dumps(context, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (ApiError, ValueError) as error:
print(f"测试失败: {error}", file=sys.stderr)
raise SystemExit(1)

134
tests/test_upload_skill.py Normal file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Regression tests for the project-embedded safe upload Skill."""
from __future__ import annotations
import argparse
import contextlib
import importlib.util
import io
import json
import sys
import tempfile
from pathlib import Path
sys.dont_write_bytecode = True
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / ".agents" / "skills" / "upload-delivery-desk-work" / "scripts" / "delivery_desk_upload.py"
SPEC = importlib.util.spec_from_file_location("delivery_desk_upload", SCRIPT)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
PROJECT = {"group_id": 7, "group_name": "精确运营组", "id": 12, "name": "光影项目", "slug": "light-project", "status": "active"}
IDENTITY = MODULE.project_identity(PROJECT)
CONTENT = {"title": "作品标题", "description": "正文", "tags": ["#测试"], "images": ["https://cdn.example.com/01.jpg"]}
def make_plan(operation: str) -> dict:
value = {"schema_version": 1, "operation": operation, "base_url": "http://example.invalid", "target": IDENTITY, "content": CONTENT}
if operation == "create_work":
value["external_id"] = "agent-test-001"
else:
value["work"] = {"work_id": 34, "external_id": "existing-34", "expected_version_number": 2, "current_round": 2, "next_round": 3}
value["confirmation_code"] = MODULE.confirmation_code(value)
return value
def write_plan(folder: Path, value: dict) -> Path:
path = folder / "plan.json"
path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
return path
def detail(version: int, *, work_id: int = 88, image_url: str | None = None) -> dict:
return {"id": work_id, "project": {"id": 12}, "external_id": "agent-test-001", "version_number": version, "title": CONTENT["title"], "description": CONTENT["description"], "tags": CONTENT["tags"], "images": [{"url": image_url or CONTENT["images"][0]}], "rounds": [{"round_number": version}]}
def apply_silently(path: Path, code: str) -> dict:
output = io.StringIO()
with contextlib.redirect_stdout(output):
MODULE.cmd_apply(argparse.Namespace(plan=str(path), confirm=code))
return json.loads(output.getvalue())
def main() -> None:
original_exact_project = MODULE.exact_project
original_request_json = MODULE.request_json
original_get_work = MODULE.get_work
MODULE.exact_project = lambda _base, _project_id: PROJECT
try:
with tempfile.TemporaryDirectory() as folder_name:
folder = Path(folder_name)
work_plan = make_plan("create_work")
work_path = write_plan(folder, work_plan)
calls: list[tuple] = []
MODULE.request_json = lambda *_args, **_kwargs: calls.append((_args, _kwargs))
try:
apply_silently(work_path, "WRONG")
raise AssertionError("错误确认码未被阻止")
except MODULE.UploadError as error:
assert "确认码不匹配" in str(error)
assert error.action == "revise" and "确认码" in error.next_step
assert not calls
MODULE.request_json = lambda *_args, **_kwargs: (200, [{"id": 88, "external_id": "agent-test-001"}])
result = apply_silently(work_path, work_plan["confirmation_code"])
assert result["idempotent"] is True and result["work"]["id"] == 88
create_calls: list[tuple[str, str]] = []
def create_request(_base: str, path: str, *, method: str = "GET", body=None):
create_calls.append((method, path))
return (200, []) if method == "GET" else (201, {"id": 88})
MODULE.request_json = create_request
MODULE.get_work = lambda *_args, **_kwargs: detail(1, image_url="https://cos.example.com/imports/normalized.jpg")
result = apply_silently(work_path, work_plan["confirmation_code"])
assert result["work_id"] == 88 and result["image_count"] == 1
assert [method for method, _path in create_calls].count("POST") == 1
round_plan = make_plan("create_round")
round_path = write_plan(folder, round_plan)
MODULE.get_work = lambda *_args, **_kwargs: detail(3, work_id=34)
post_calls: list[tuple] = []
MODULE.request_json = lambda *_args, **_kwargs: post_calls.append((_args, _kwargs))
try:
apply_silently(round_path, round_plan["confirmation_code"])
raise AssertionError("版本漂移未被阻止")
except MODULE.UploadError as error:
assert "当前版本已变化" in str(error)
assert error.action == "ask_operator"
assert not post_calls
work_reads = iter([detail(2, work_id=34), detail(3, work_id=34)])
MODULE.get_work = lambda *_args, **_kwargs: next(work_reads)
MODULE.request_json = lambda *_args, **_kwargs: (201, {"id": 34})
result = apply_silently(round_path, round_plan["confirmation_code"])
assert result["work_id"] == 34 and result["round_number"] == 3
try:
MODULE.assert_work_project({"project": {"id": 99}}, 12)
raise AssertionError("错误项目归属未被阻止")
except MODULE.UploadError as error:
assert "不是已确认项目" in str(error)
revised = MODULE.http_failure("POST", "/api/projects/12/works", 422, "图片不可用")
assert revised.action == "revise" and "重新生成计划" in revised.next_step
unauthorized = MODULE.http_failure("GET", "/api/projects", 401, "无效 Key")
assert unauthorized.action == "ask_operator" and "DELIVERY_DESK_API_KEY" in unauthorized.next_step
transient_read = MODULE.http_failure("GET", "/api/projects", 503, "暂时不可用")
assert transient_read.action == "retry" and "只读" in transient_read.next_step
uncertain_write = MODULE.http_failure("POST", "/api/works/34/rounds", 503, "暂时不可用")
assert uncertain_write.action == "ask_operator" and "禁止自动重试" in uncertain_write.next_step
private_url = MODULE.http_failure("POST", "/api/projects/12/works", 400, "图片地址不允许访问私有网络")
assert private_url.action == "revise" and "修改输入" in private_url.next_step
finally:
MODULE.exact_project = original_exact_project
MODULE.request_json = original_request_json
MODULE.get_work = original_get_work
print("Upload Skill regression tests passed")
if __name__ == "__main__":
main()