Compare commits
6 Commits
e4d1d3bcea
...
69cfd0b51d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69cfd0b51d | ||
|
|
3bfb481c71 | ||
|
|
b8b4d7a11c | ||
|
|
e7e268d4eb | ||
|
|
6091d61612 | ||
|
|
721e971dd8 |
164
.agents/skills/upload-delivery-desk-work/SKILL.md
Normal file
164
.agents/skills/upload-delivery-desk-work/SKILL.md
Normal 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.
|
||||||
@@ -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."
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
10
AGENTS.md
10
AGENTS.md
@@ -1,8 +1,10 @@
|
|||||||
# Delivery Desk 开发约定
|
# Delivery Desk 开发约定
|
||||||
|
|
||||||
- 包管理器使用 pnpm;提交前运行 `pnpm check`、`pnpm lint`、`pnpm build` 和 `pnpm test:postgres-runtime`。
|
- 包管理器使用 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` 只是内部数据库与路由标识,用户界面和文档不再称“作品集”或“阶段任务”。
|
- 业务术语统一为“运营组 → 项目 → 作品 → 验收轮次”;每轮只有一个方案。`collections` 和 `work_versions` 是迁移期内部兼容结构,不得出现在新产品界面或新 API 命名中。
|
||||||
- 数据库结构变更必须同时更新 `api/db.ts`、`db/postgres/schema.sql` 和相关迁移验证。
|
- 新接口使用 `/api/projects/:projectId/works` 和 `/api/works/:workId/rounds`;`notes`、`collections`、`versions` 路由只做一个兼容周期,不再扩展。
|
||||||
|
- 数据库结构变更必须同时更新 `api/db.ts`、`db/postgres/schema.sql` 和迁移验证。
|
||||||
- `data/`、`uploads/`、`.env*`、COS 凭证、数据库文件及用户上传内容不得提交。
|
- `data/`、`uploads/`、`.env*`、COS 凭证、数据库文件及用户上传内容不得提交。
|
||||||
- 本地开发可使用 SQLite;正式部署使用 PostgreSQL。COS 配置只通过平台管理界面或部署密钥注入,不写入源码。
|
- 本地开发可使用 SQLite;正式部署使用 PostgreSQL。COS 配置只通过平台管理界面或部署密钥注入。
|
||||||
- 不把 `.trae/` 中的早期原型文档作为现行依据;以 README、`docs/` 和当前代码为准。
|
- 不把 `.trae/` 中的早期原型文档作为现行依据;以 README、`docs/` 和当前代码为准。
|
||||||
|
- Agent 通过 API 上传作品或创建验收轮次时必须使用 `.agents/skills/upload-delivery-desk-work`;接口或层级变化后同步更新该 Skill,并运行 `powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1` 重新打包。
|
||||||
|
|||||||
84
README.md
84
README.md
@@ -1,27 +1,20 @@
|
|||||||
# 交付工作台(Delivery Desk)
|
# 交付工作台(Delivery Desk)
|
||||||
|
|
||||||
面向图文作品交付与客户验收的响应式 Web 工作台。业务层级为“运营组 → 项目 → 作品交付集 → 作品 → 版本”。运营人员负责上传和处理反馈,客户通过项目链接完成查看、批注与验收。
|
面向图文作品交付与客户验收的响应式 Web 工作台。当前产品层级为“运营组 → 项目 → 作品 → 验收轮次”;每轮只有一个方案。
|
||||||
|
|
||||||
## 当前能力
|
## 当前能力
|
||||||
|
|
||||||
- 平台管理员、组管理员、光影叙事三类账号及运营组数据隔离
|
- 平台管理员、组管理员、光影叙事三类账号及运营组数据隔离
|
||||||
- 项目、作品交付集、作品和作品版本管理
|
- 项目、作品、单方案验收轮次和项目级自动验收状态
|
||||||
- 多图上传、封面预览、图片排序和腾讯云 COS 存储
|
- 手动多图上传、公开图片 URL 自动归一到腾讯云 COS、拖拽排序
|
||||||
- 图片坐标批注、标题/正文批注、总体反馈和验收记录
|
- 作品缩略图浏览;悬浮图片窗格中的原图查看、缩放和坐标批注
|
||||||
|
- 标题、正文和 Tag 选区批注、作品总体反馈和验收记录
|
||||||
- 客户项目密码、访问期限和独立验收入口
|
- 客户项目密码、访问期限和独立验收入口
|
||||||
- 平台级/项目级 API Key、审计日志和账号管理
|
- 平台级/项目级 API Key、审计日志和账号管理
|
||||||
- SQLite 本地开发、PostgreSQL 正式运行及迁移脚本
|
- 项目内置 Agent 安全上传 Skill,使用双阶段确认防止错组、错项目和错作品
|
||||||
- Docker 单机部署
|
- SQLite 本地开发、PostgreSQL 正式运行及 Docker 部署
|
||||||
|
|
||||||
尚未落地的范围见 [初版交接说明](docs/handoff.md)。
|
未落地范围见 [初版交接说明](docs/handoff.md)。
|
||||||
|
|
||||||
## 技术结构
|
|
||||||
|
|
||||||
- React 18、TypeScript、Vite、Tailwind CSS
|
|
||||||
- Express API
|
|
||||||
- 本地开发:SQLite 与本地 `uploads`
|
|
||||||
- 正式环境:PostgreSQL、腾讯云 COS
|
|
||||||
- COS SecretId/SecretKey 由平台管理员在前端配置,服务端使用 AES-256-GCM 加密,接口不返回明文
|
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
@@ -32,61 +25,42 @@ pnpm install
|
|||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- 前端:`http://localhost:5180`
|
- 前端:http://localhost:5180
|
||||||
- API:`http://localhost:3010`
|
- API:http://localhost:3010
|
||||||
- 健康检查:`http://localhost:3010/api/health`
|
- 健康检查: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。
|
||||||
|
|
||||||
## 环境配置
|
## PostgreSQL 与 Docker
|
||||||
|
|
||||||
复制 `.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 迁移
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm db:postgres:validate
|
pnpm db:postgres:validate
|
||||||
pnpm db:postgres:migrate
|
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
|
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
|
```bash
|
||||||
pnpm check
|
pnpm check
|
||||||
pnpm lint
|
pnpm lint
|
||||||
pnpm build
|
pnpm build
|
||||||
|
pnpm test:review-rounds
|
||||||
|
pnpm test:collection-status
|
||||||
pnpm test:postgres-runtime
|
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)
|
||||||
|
|||||||
11
api/app.ts
11
api/app.ts
@@ -22,6 +22,8 @@ import groupsRoutes from './routes/groups.js';
|
|||||||
import managementRoutes from './routes/management.js';
|
import managementRoutes from './routes/management.js';
|
||||||
import storageRoutes from './routes/storage.js';
|
import storageRoutes from './routes/storage.js';
|
||||||
import reviewRoutes from './routes/review.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 { UPLOADS_DIR } from './upload.js';
|
||||||
import { database, databaseDialect } from './database.js';
|
import { database, databaseDialect } from './database.js';
|
||||||
|
|
||||||
@@ -50,6 +52,8 @@ app.use(
|
|||||||
* API 路由
|
* API 路由
|
||||||
*/
|
*/
|
||||||
app.use('/api/notes', notesRoutes);
|
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/images', imagesRoutes);
|
||||||
app.use('/api/annotations', annotationsRoutes);
|
app.use('/api/annotations', annotationsRoutes);
|
||||||
app.use('/api/projects', projectsRoutes);
|
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) => {
|
app.use((err: Error, _req: Request, res: Response, _next: unknown) => {
|
||||||
void _next;
|
void _next;
|
||||||
console.error('[API Error]', err);
|
|
||||||
// multer 文件类型/大小错误
|
// multer 文件类型/大小错误
|
||||||
const message = err.message || '服务器内部错误';
|
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('不支持的文件类型')) {
|
if (err.message?.includes('不支持的文件类型')) {
|
||||||
res.status(400).json({ success: false, error: message });
|
res.status(400).json({ success: false, error: message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.error('[API Error]', err);
|
||||||
res.status(500).json({ success: false, error: message });
|
res.status(500).json({ success: false, error: message });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) });
|
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');
|
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);
|
await pool.query(schema);
|
||||||
const userCount = Number((await pool.query('SELECT COUNT(*)::int AS count FROM users')).rows[0].count);
|
const userCount = Number((await pool.query('SELECT COUNT(*)::int AS count FROM users')).rows[0].count);
|
||||||
if (userCount === 0) {
|
if (userCount === 0) {
|
||||||
|
|||||||
169
api/db.ts
169
api/db.ts
@@ -102,6 +102,8 @@ db.exec(`
|
|||||||
slug TEXT NOT NULL UNIQUE,
|
slug TEXT NOT NULL UNIQUE,
|
||||||
client_description TEXT NOT NULL DEFAULT '',
|
client_description TEXT NOT NULL DEFAULT '',
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
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'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS collections (
|
CREATE TABLE IF NOT EXISTS collections (
|
||||||
@@ -109,12 +111,14 @@ db.exec(`
|
|||||||
project_id INTEGER NOT NULL,
|
project_id INTEGER NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
client_description TEXT NOT NULL DEFAULT '',
|
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')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS notes (
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
project_id INTEGER,
|
||||||
external_id TEXT,
|
external_id TEXT,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
@@ -135,16 +139,23 @@ db.exec(`
|
|||||||
x REAL NOT NULL,
|
x REAL NOT NULL,
|
||||||
y REAL NOT NULL,
|
y REAL NOT NULL,
|
||||||
content TEXT 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')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE
|
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS work_comments (
|
CREATE TABLE IF NOT EXISTS work_comments (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
note_id INTEGER NOT NULL,
|
note_id INTEGER NOT NULL,
|
||||||
|
version_number INTEGER NOT NULL DEFAULT 1,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
author_name TEXT NOT NULL DEFAULT '客户',
|
author_name TEXT NOT NULL DEFAULT '客户',
|
||||||
author_role TEXT NOT NULL DEFAULT 'client',
|
author_role TEXT NOT NULL DEFAULT 'client',
|
||||||
status TEXT NOT NULL DEFAULT 'open',
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
closure_reason TEXT NOT NULL DEFAULT '',
|
||||||
|
withdrawn_at TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
|
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
@@ -153,9 +164,30 @@ db.exec(`
|
|||||||
note_id INTEGER NOT NULL,
|
note_id INTEGER NOT NULL,
|
||||||
version_number INTEGER NOT NULL,
|
version_number INTEGER NOT NULL,
|
||||||
target TEXT 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,
|
content TEXT NOT NULL,
|
||||||
author_name TEXT NOT NULL DEFAULT '客户',
|
author_name TEXT NOT NULL DEFAULT '客户',
|
||||||
|
author_role TEXT NOT NULL DEFAULT 'client',
|
||||||
status TEXT NOT NULL DEFAULT 'open',
|
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')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
|
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 (note_id) REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
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 (
|
CREATE TABLE IF NOT EXISTS review_events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
note_id INTEGER NOT NULL,
|
note_id INTEGER NOT NULL,
|
||||||
@@ -197,13 +243,21 @@ function addColumn(table: string, definition: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addColumn('notes', "collection_id INTEGER");
|
addColumn('notes', "collection_id INTEGER");
|
||||||
|
addColumn('notes', 'project_id INTEGER');
|
||||||
addColumn('notes', 'external_id TEXT');
|
addColumn('notes', 'external_id TEXT');
|
||||||
addColumn('notes', "tags TEXT NOT NULL DEFAULT '[]'");
|
addColumn('notes', "tags TEXT NOT NULL DEFAULT '[]'");
|
||||||
addColumn('notes', "review_status TEXT NOT NULL DEFAULT 'pending'");
|
addColumn('notes', "review_status TEXT NOT NULL DEFAULT 'pending'");
|
||||||
addColumn('notes', 'version_number INTEGER NOT NULL DEFAULT 1');
|
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', "author_name TEXT NOT NULL DEFAULT '客户'");
|
||||||
addColumn('annotations', "status TEXT NOT NULL DEFAULT 'open'");
|
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', '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', "access_password_hash TEXT NOT NULL DEFAULT ''");
|
||||||
addColumn('projects', 'customer_access_enabled INTEGER NOT NULL DEFAULT 0');
|
addColumn('projects', 'customer_access_enabled INTEGER NOT NULL DEFAULT 0');
|
||||||
addColumn('projects', 'access_expires_at TEXT');
|
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', "storage_key TEXT NOT NULL DEFAULT ''");
|
||||||
addColumn('images', 'version_number INTEGER NOT NULL DEFAULT 1');
|
addColumn('images', 'version_number INTEGER NOT NULL DEFAULT 1');
|
||||||
addColumn('users', 'last_login_at TEXT');
|
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 != ''");
|
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 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 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 = '本月内容作品交付集'
|
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();
|
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)
|
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();
|
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(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_collections_project_id ON collections(project_id);
|
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_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_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_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_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_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_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_token_hash ON customer_sessions(token_hash);
|
||||||
CREATE INDEX IF NOT EXISTS idx_customer_sessions_project_id ON customer_sessions(project_id);
|
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_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_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_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);
|
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;
|
export default db;
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ function toAnnotation(row: AnnotationRow): Annotation { return { ...row, id: Num
|
|||||||
|
|
||||||
export const annotationsRepository = {
|
export const annotationsRepository = {
|
||||||
async listByImage(imageId: number): Promise<Annotation[]> {
|
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> {
|
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 || '客户']);
|
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, status, created_at FROM annotations WHERE id = ?', [id]))!);
|
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; },
|
async remove(id: number): Promise<boolean> { return (await database.execute('DELETE FROM annotations WHERE id = ?', [id])).changes > 0; },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,15 +2,16 @@ import { database } from '../database.js';
|
|||||||
import type { Note, NoteListQuery, ReviewStatus } from '../../shared/types.js';
|
import type { Note, NoteListQuery, ReviewStatus } from '../../shared/types.js';
|
||||||
|
|
||||||
interface NoteRow {
|
interface NoteRow {
|
||||||
id: number; collection_id: number; external_id: string | null; title: string; description: string; tags: 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; created_at: 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;
|
image_count: number | string; annotation_count: number | string; comment_count: number | string; cover_url: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toNote(row: NoteRow): Note {
|
function toNote(row: NoteRow): Note {
|
||||||
return {
|
return {
|
||||||
...row,
|
...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),
|
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[],
|
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}`) : '',
|
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 = `
|
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(ic.image_count, 0) AS image_count,
|
||||||
COALESCE(ac.annotation_count, 0) AS annotation_count,
|
COALESCE(ac.annotation_count, 0) AS annotation_count,
|
||||||
COALESCE(cc.comment_count, 0) AS comment_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.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.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.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.projectId) { conditions.push('n.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.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 where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
|
||||||
const order = query.order === 'asc' ? 'ASC' : 'DESC';
|
const order = query.order === 'asc' ? 'ASC' : 'DESC';
|
||||||
const sort = query.sort === 'annotations' ? 'annotation_count' : 'n.created_at';
|
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]);
|
const row = await database.one<NoteRow>(`${select} WHERE n.collection_id = ? AND n.external_id = ?`, [collectionId, externalId]);
|
||||||
return row ? toNote(row) : null;
|
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> {
|
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']);
|
return database.insertId('INSERT INTO notes (title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?)', [title, description, collectionId, JSON.stringify(tags), 'pending']);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Router, type Response } from 'express';
|
import { Router, type Response } from 'express';
|
||||||
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
|
||||||
import { canWriteProject, requireWriter, type AuthRequest } from '../auth.js';
|
import { canWriteProject, requireWriter, type AuthRequest } from '../auth.js';
|
||||||
import { database } from '../database.js';
|
import { database } from '../database.js';
|
||||||
|
|
||||||
@@ -8,10 +7,13 @@ const router = Router();
|
|||||||
router.delete('/:annotationId', requireWriter, async (req: AuthRequest, res: Response) => {
|
router.delete('/:annotationId', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||||
const id = Number(req.params.annotationId);
|
const id = Number(req.params.annotationId);
|
||||||
if (!Number.isFinite(id)) { res.status(400).json({ error: '无效的批注 ID' }); return; }
|
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 (!context) { res.status(404).json({ error: '批注不存在' }); return; }
|
||||||
if (!await canWriteProject(req, context.project_id)) { res.status(403).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();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,20 +7,22 @@ const router = Router();
|
|||||||
|
|
||||||
router.post('/notes/:noteId/comments', requireWriter, async (req: AuthRequest, res: Response) => {
|
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 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 (!context) { res.status(404).json({ error: '作品不存在' }); return; }
|
||||||
if (!await canWriteProject(req, context.project_id)) { res.status(403).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: '回复内容须为 1–2000 个字符' }); return; }
|
if (!content || content.length > 2000) { res.status(400).json({ error: '回复内容须为 1–2000 个字符' }); 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]));
|
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) => {
|
router.patch('/comments/:commentId', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||||
const id = Number(req.params.commentId); const status = req.body?.status;
|
const id = Number(req.params.commentId); const status = req.body?.status;
|
||||||
if (!['open', 'resolved', 'confirmed'].includes(status)) { res.status(400).json({ error: '无效状态' }); return; }
|
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 (!context) { res.status(404).json({ error: '反馈不存在' }); return; }
|
||||||
if (!await canWriteProject(req, context.project_id)) { res.status(403).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]);
|
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]));
|
res.json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { database } from '../database.js';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
async function imageProjectId(imageId: number): Promise<number | undefined> {
|
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) => {
|
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);
|
const imageId = Number(req.params.imageId);
|
||||||
if (!Number.isFinite(imageId)) { res.status(400).json({ error: '无效的图片 ID' }); return; }
|
if (!Number.isFinite(imageId)) { res.status(400).json({ error: '无效的图片 ID' }); return; }
|
||||||
if (!await imagesRepository.findById(imageId)) { res.status(404).json({ error: '图片不存在' }); return; }
|
if (!await imagesRepository.findById(imageId)) { res.status(404).json({ error: '图片不存在' }); return; }
|
||||||
const projectId = await imageProjectId(imageId);
|
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 (!projectId || !await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权操作该图片' }); return; }
|
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 { x, y } = req.body ?? {};
|
||||||
const content = String(req.body?.content ?? '').trim();
|
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 (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; }
|
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); }
|
} catch (error) { next(error); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
import { Router, type Response, type NextFunction } from 'express';
|
import { Router, type Response, type NextFunction } from 'express';
|
||||||
import { upload } from '../upload.js';
|
import { upload } from '../upload.js';
|
||||||
import { notesService } from '../services/notesService.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 { audit, canWriteProject, requireRole, requireWriter, type AuthRequest } from '../auth.js';
|
||||||
import { database, withTransaction } from '../database.js';
|
import { database, withTransaction } from '../database.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import type { TextAnnotation } from '../../shared/types.js';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -29,6 +30,15 @@ function parseImageUrls(value: unknown): { valid: boolean; urls: string[] } {
|
|||||||
return { valid, urls };
|
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 - 笔记列表
|
// GET /api/notes - 笔记列表
|
||||||
router.get('/', requireWriter, async (req: AuthRequest, res: Response) => {
|
router.get('/', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||||
const { sort, order, q, collectionId, status, tag, externalId } = req.query as {
|
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) => {
|
router.post('/:noteId/text-annotations', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||||
const noteId = Number(req.params.noteId);
|
res.setHeader('Deprecation', 'true');
|
||||||
const versionNumber = Number(req.body?.version_number);
|
res.status(410).json({ error: '该接口已停用,请使用 /api/works/:workId/text-annotations 并提交明确的文字选区' });
|
||||||
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: '批注内容须为 1–1000 个字符' }); 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]));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/notes - 上传新笔记 (multipart/form-data)
|
// POST /api/notes - 上传新笔记 (multipart/form-data)
|
||||||
@@ -113,12 +113,13 @@ router.post(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
|
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)) {
|
if (!collection || !await canWriteProject(req, collection.project_id)) {
|
||||||
files.forEach((file) => { try { fs.unlinkSync(file.path); } catch { /* uploaded file may already be gone */ } });
|
files.forEach((file) => { try { fs.unlinkSync(file.path); } catch { /* uploaded file may already be gone */ } });
|
||||||
res.status(collection ? 403 : 404).json({ error: collection ? '无权向该作品交付集上传作品' : '作品交付集不存在' });
|
res.status(collection ? 403 : 404).json({ error: collection ? '无权向该作品交付集上传作品' : '作品交付集不存在' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (collection.project_status !== 'active') { files.forEach((file) => { try { fs.unlinkSync(file.path); } catch { /* noop */ } }); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
|
||||||
if (externalId) {
|
if (externalId) {
|
||||||
const existing = await notesService.findByExternalId(collectionId, externalId);
|
const existing = await notesService.findByExternalId(collectionId, externalId);
|
||||||
if (existing) { res.status(200).json({ ...existing, idempotent: true }); return; }
|
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) ?? [];
|
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
|
||||||
try {
|
try {
|
||||||
const id = Number(req.params.noteId);
|
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 (!context) { res.status(404).json({ error: '作品不存在' }); return; }
|
||||||
if (!await canWriteProject(req, context.project_id)) { res.status(403).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);
|
const { valid: validImageUrls, urls: imageUrls } = parseImageUrls(req.body.images);
|
||||||
if (!validImageUrls) { res.status(400).json({ error: 'images 需要包含 1–30 个有效的 HTTP/HTTPS 图片 URL' }); return; }
|
if (!validImageUrls) { res.status(400).json({ error: 'images 需要包含 1–30 个有效的 HTTP/HTTPS 图片 URL' }); return; }
|
||||||
if (!files.length && !imageUrls.length) { res.status(400).json({ error: '新版本至少需要一张图片' }); 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); }
|
} 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: '候选稿名称须为 1–30 个字符,标题不能为空' }); 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) => {
|
router.patch('/:noteId/status', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||||
const id = Number(req.params.noteId);
|
const id = Number(req.params.noteId);
|
||||||
const status = req.body?.status;
|
const status = req.body?.status;
|
||||||
@@ -181,8 +230,10 @@ router.patch('/:noteId/status', requireWriter, async (req: AuthRequest, res: Res
|
|||||||
res.status(400).json({ error: '无效的验收状态' });
|
res.status(400).json({ error: '无效的验收状态' });
|
||||||
return;
|
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 && !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)) {
|
if (!await notesService.setStatus(id, status)) {
|
||||||
res.status(404).json({ error: '作品不存在' });
|
res.status(404).json({ error: '作品不存在' });
|
||||||
return;
|
return;
|
||||||
@@ -194,15 +245,20 @@ router.post('/:noteId/reopen', requireRole('platform_admin', 'group_admin'), asy
|
|||||||
const id = Number(req.params.noteId);
|
const id = Number(req.params.noteId);
|
||||||
const reason = String(req.body?.reason ?? '').trim();
|
const reason = String(req.body?.reason ?? '').trim();
|
||||||
if (!reason) { res.status(400).json({ error: '重新打开验收时必须填写原因' }); return; }
|
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 (!note) { res.status(404).json({ error: '作品不存在' }); return; }
|
||||||
if (!await canWriteProject(req, note.project_id)) { res.status(403).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; }
|
if (note.review_status !== 'approved') { res.status(409).json({ error: '只有已通过作品可以重新打开' }); return; }
|
||||||
const actor = req.authUser!;
|
const actor = req.authUser!;
|
||||||
await withTransaction(async (tx) => {
|
await withTransaction(async (tx) => {
|
||||||
await tx.execute("UPDATE notes SET review_status = 'pending' WHERE id = ?", [id]);
|
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 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 });
|
await audit(req, 'work.reopen', 'work', id, { reason, versionNumber: note.version_number });
|
||||||
res.json({ success: true, status: 'pending' });
|
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' });
|
res.status(400).json({ error: '无效的笔记 ID' });
|
||||||
return;
|
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 && !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);
|
const ok = await notesService.remove(id);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
res.status(404).json({ error: '笔记不存在' });
|
res.status(404).json({ error: '笔记不存在' });
|
||||||
|
|||||||
72
api/routes/projectWorks.ts
Normal file
72
api/routes/projectWorks.ts
Normal 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: '请提供 1–30 张上传图片或公开图片 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;
|
||||||
@@ -5,22 +5,29 @@ import type { Project, WorkCollection } from '../../shared/types.js';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const reader = requireWriter;
|
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 };
|
type CollectionRow = WorkCollection & { work_count: number | string; approved_count: number | string };
|
||||||
|
|
||||||
function projectSelect(where: 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,
|
p.customer_access_enabled, p.access_expires_at,
|
||||||
CASE WHEN p.access_password_hash != '' THEN 1 ELSE 0 END AS has_access_password,
|
CASE WHEN p.access_password_hash != '' THEN 1 ELSE 0 END AS has_access_password,
|
||||||
COALESCE(cc.collection_count, 0) AS collection_count,
|
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
|
FROM projects p
|
||||||
JOIN operation_groups g ON g.id = p.group_id
|
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 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}`;
|
${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) }; }
|
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) => {
|
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)=>{
|
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 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)=>{
|
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)=>{
|
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}
|
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)=>{
|
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}
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -1,32 +1,176 @@
|
|||||||
import { Router, type Response } from 'express';
|
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 { createCustomerSession, customerSessionCookie, optionalCustomer, requireCustomerProject, type CustomerRequest } from '../customerAuth.js';
|
||||||
import { verifyPassword } from '../auth.js';
|
import { verifyPassword } from '../auth.js';
|
||||||
import { notesService } from '../services/notesService.js';
|
import { notesService } from '../services/notesService.js';
|
||||||
import { annotationsRepository } from '../repositories/annotationsRepository.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);
|
const router = Router();
|
||||||
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};
|
router.use(optionalCustomer);
|
||||||
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]);
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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/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/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.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:'请填写 2–30 个字符的姓名'});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.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: '请填写 2–30 个字符的姓名' }); 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/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/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.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.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.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/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/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:'反馈内容须为 1–2000 个字符'});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/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/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:'批注内容须为 1–1000 个字符'});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.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: '反馈内容须为 1–2000 个字符' }); 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/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:'批注内容须为 1–1000 个字符'});return}res.status(201).json(await annotationsRepository.create(imageId,{x,y,content,author_name:req.customer!.reviewer_name}))});
|
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: '批注内容须为 1–1000 个字符' }); 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/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/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: '批注内容须为 1–1000 个字符' }); 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: '回复内容须为 1–1000 个字符' }); 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;
|
export default router;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Router, type Response } from 'express';
|
|||||||
import { audit, requireRole, type AuthRequest } from '../auth.js';
|
import { audit, requireRole, type AuthRequest } from '../auth.js';
|
||||||
import { encryptSecret } from '../configCrypto.js';
|
import { encryptSecret } from '../configCrypto.js';
|
||||||
import { database, withTransaction } from '../database.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();
|
const router=Router();
|
||||||
type PublicRow=Record<string,unknown>&{id:number|string;has_credentials:boolean|number};
|
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}}
|
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.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/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})});
|
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;
|
export default router;
|
||||||
|
|||||||
182
api/routes/works.ts
Normal file
182
api/routes/works.ts
Normal 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: '每轮必须提供 1–30 张图片' }); 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: '批注内容须为 1–1000 个字符' }); 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: '反馈内容须为 1–2000 个字符' }); 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: '回复内容须为 1–1000 个字符' }); 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;
|
||||||
46
api/services/collectionsService.ts
Normal file
46
api/services/collectionsService.ts
Normal 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 };
|
||||||
|
}
|
||||||
30
api/services/feedbackService.ts
Normal file
30
api/services/feedbackService.ts
Normal 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]);
|
||||||
|
}
|
||||||
@@ -1,105 +1,252 @@
|
|||||||
import sharp from 'sharp';
|
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 { notesRepository } from '../repositories/notesRepository.js';
|
||||||
import { imagesRepository } from '../repositories/imagesRepository.js';
|
import { imagesRepository } from '../repositories/imagesRepository.js';
|
||||||
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
||||||
import { database, withTransaction } from '../database.js';
|
import { database, databaseDialect, withTransaction, type QueryContext } from '../database.js';
|
||||||
import { storeUploadedFile } from '../storage.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 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 }> {
|
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 }; }
|
try { const meta = await sharp(filePath).metadata(); return { width: meta.width ?? 0, height: meta.height ?? 0 }; }
|
||||||
catch { return { width: 0, 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)) })));
|
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 = {
|
export const notesService = {
|
||||||
async list(query: { sort?: string; order?: string; q?: string; collectionId?: number; status?: ReviewStatus; tag?: string; projectId?: number; groupId?: number; externalId?: string }) {
|
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 });
|
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);
|
const current = await notesRepository.findById(id);
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
const selectedVersion = requestedVersion && requestedVersion !== current.version_number
|
const requested = requestedRound
|
||||||
? 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])
|
? 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;
|
: 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 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 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]);
|
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 (!workContext) return null;
|
if (!project) 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 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 = {
|
const result: NoteDetail = {
|
||||||
...note,
|
...note,
|
||||||
images: [] as ImageWithAnnotations[],
|
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]),
|
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 = ? ORDER BY id ASC', [id]),
|
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]),
|
||||||
versions: versionRows.map((item) => ({ ...item, version_number: Number(item.version_number), tags: JSON.parse(item.tags || '[]') as string[] })),
|
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]),
|
||||||
review_events: await database.all<NoteDetail['review_events'][number]>('SELECT * FROM review_events WHERE note_id = ? ORDER BY id DESC', [id]),
|
rounds: roundRows.map(mapRound),
|
||||||
project: { id: Number(workContext.project_id), name: workContext.project_name, slug: workContext.slug },
|
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]),
|
||||||
collection: { id: Number(workContext.collection_id), name: workContext.collection_name },
|
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) });
|
for (const image of images) result.images.push({ ...image, annotations: await annotationsRepository.listByImage(image.id) });
|
||||||
return result;
|
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 prepared = await prepareFiles(files);
|
||||||
const noteId = await withTransaction(async (tx) => {
|
return 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']);
|
const collectionId = await ensureProjectCompatibilityCollection(projectId, tx);
|
||||||
await imagesRepository.createMany(id, prepared, 1, 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 tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]);
|
await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft');
|
||||||
return id;
|
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> {
|
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 collection = await database.one<{ project_id: number }>('SELECT project_id FROM collections WHERE id = ?', [collectionId]);
|
||||||
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']);
|
if (!collection) throw new Error('作品交付集不存在');
|
||||||
await imagesRepository.createMany(id, imageUrls.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external' as const, storageKey: '' })), 1, tx);
|
const projectId = Number(collection.project_id);
|
||||||
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)]);
|
const prepared = await prepareExternalImages(imageUrls);
|
||||||
return id;
|
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> {
|
async findByExternalId(collectionId: number, externalId: string): Promise<Note | null> { return notesRepository.findByExternalId(collectionId, externalId); },
|
||||||
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> {
|
async createVersion(id: number, title: string, description: string, files: UploadedFile[], tags: string[], createdBy?: number): Promise<Note> {
|
||||||
const current = await notesRepository.findById(id);
|
return this.createRound(id, { title, description, tags, files }, createdBy);
|
||||||
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))!;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async createVersionFromUrls(id: number, title: string, description: string, imageUrls: string[], tags: string[], createdBy?: number): Promise<Note> {
|
async createVersionFromUrls(id: number, title: string, description: string, imageUrls: string[], tags: string[], createdBy?: number): Promise<Note> {
|
||||||
const current = await notesRepository.findById(id);
|
return this.createRoundFromUrls(id, { title, description, tags, images: imageUrls }, createdBy);
|
||||||
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))!;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async remove(id: number) { return notesRepository.remove(id); },
|
async getFeedback(id: number): Promise<WorkFeedbackBundle | null> {
|
||||||
async setStatus(id: number, status: ReviewStatus) { return notesRepository.setStatus(id, status); },
|
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;
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
55
api/services/projectsService.ts
Normal file
55
api/services/projectsService.ts
Normal 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__'],
|
||||||
|
);
|
||||||
|
}
|
||||||
64
api/services/reviewService.ts
Normal file
64
api/services/reviewService.ts
Normal 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;
|
||||||
146
api/storage.ts
146
api/storage.ts
@@ -1,7 +1,10 @@
|
|||||||
import COS from 'cos-nodejs-sdk-v5';
|
import COS from 'cos-nodejs-sdk-v5';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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 { database } from './database.js';
|
||||||
import { decryptSecret } from './configCrypto.js';
|
import { decryptSecret } from './configCrypto.js';
|
||||||
|
|
||||||
@@ -22,6 +25,28 @@ export interface StoredUpload {
|
|||||||
storageKey: string;
|
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> {
|
export async function getActiveStorageConfig(): Promise<StorageConfigRecord | undefined> {
|
||||||
return database.one<StorageConfigRecord>(`
|
return database.one<StorageConfigRecord>(`
|
||||||
SELECT id, region, bucket, public_base_url, cdn_domain, path_prefix,
|
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('/')}`;
|
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 {
|
function safeError(error: unknown): string {
|
||||||
if (!error || typeof error !== 'object') return 'COS 连接失败';
|
if (!error || typeof error !== 'object') return 'COS 连接失败';
|
||||||
const item = error as { code?: string; statusCode?: number; message?: string };
|
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);
|
fs.unlinkSync(file.path);
|
||||||
return { url: objectUrl(config, key), storageProvider: 'tencent_cos', storageKey: key };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS projects (
|
|||||||
slug TEXT NOT NULL UNIQUE,
|
slug TEXT NOT NULL UNIQUE,
|
||||||
client_description TEXT NOT NULL DEFAULT '',
|
client_description TEXT NOT NULL DEFAULT '',
|
||||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed', 'archived')),
|
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 '',
|
access_password_hash TEXT NOT NULL DEFAULT '',
|
||||||
customer_access_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
customer_access_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
access_expires_at TIMESTAMPTZ,
|
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,
|
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
client_description TEXT NOT NULL DEFAULT '',
|
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(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
UNIQUE (project_id, name)
|
UNIQUE (project_id, name)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE collections ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS notes (
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
collection_id BIGINT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
collection_id BIGINT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
|
project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
external_id TEXT,
|
external_id TEXT,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
tags 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_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),
|
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()
|
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 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 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 (
|
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),
|
y DOUBLE PRECISION NOT NULL CHECK (y BETWEEN 0 AND 1),
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
author_name TEXT NOT NULL DEFAULT '客户',
|
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')),
|
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()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS work_comments (
|
CREATE TABLE IF NOT EXISTS work_comments (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
version_number INTEGER NOT NULL DEFAULT 1,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
author_name TEXT NOT NULL DEFAULT '客户',
|
author_name TEXT NOT NULL DEFAULT '客户',
|
||||||
author_role TEXT NOT NULL DEFAULT 'client' CHECK (author_role IN ('client', 'operator')),
|
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')),
|
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()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,15 +114,38 @@ CREATE TABLE IF NOT EXISTS text_annotations (
|
|||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
version_number INTEGER NOT NULL CHECK (version_number > 0),
|
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,
|
content TEXT NOT NULL,
|
||||||
author_name TEXT NOT NULL DEFAULT '客户',
|
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')),
|
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()
|
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 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 (
|
CREATE TABLE IF NOT EXISTS work_versions (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
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 '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
tags 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_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_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
UNIQUE (note_id, version_number)
|
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 (
|
CREATE TABLE IF NOT EXISTS review_events (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
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 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 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_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 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 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 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 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_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);
|
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;
|
COMMIT;
|
||||||
|
|||||||
@@ -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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
browser["浏览器"] --> app["Express / React 应用"]
|
browser["工作台 / 客户浏览器"] --> app["Express + React"]
|
||||||
app --> database["SQLite 或 PostgreSQL"]
|
api["外部 API 客户端"] --> app
|
||||||
app --> local["本地 uploads(开发)"]
|
app --> db["SQLite / PostgreSQL"]
|
||||||
app --> cos["腾讯云 COS(正式)"]
|
app --> storage["本地 uploads / 腾讯云 COS"]
|
||||||
customer["客户验收链接"] --> app
|
|
||||||
client["外部 API 客户端"] --> app
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 业务层级
|
## 产品层级
|
||||||
|
|
||||||
```text
|
```text
|
||||||
运营组
|
运营组
|
||||||
└── 项目
|
└── 项目
|
||||||
└── 作品交付集
|
|
||||||
└── 作品
|
└── 作品
|
||||||
└── 版本
|
└── 验收轮次(每轮一个方案)
|
||||||
```
|
```
|
||||||
|
|
||||||
- 一个运营组只能有一位组管理员,可以有多位光影叙事。
|
- 客户会话绑定项目,不能跨项目访问。
|
||||||
- 平台管理员可以有多位,不属于固定运营组。
|
- 项目级 API Key 只能访问绑定项目;平台级 Key 可跨组管理项目。
|
||||||
- 普通工作台账号只能读写所属运营组的数据;平台管理员可跨组管理。
|
- 历史作品交付集不再是产品层级。`collections` 表仅作为旧数据和旧 URL 的迁移兼容容器。
|
||||||
- 客户会话只绑定一个项目,不能跨项目浏览。
|
- `work_versions` 继续保存每轮内容快照,但与 `review_rounds` 强制一对一。
|
||||||
- 平台级 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` 及迁移验证脚本。
|
|
||||||
|
|
||||||
## 主要数据表
|
## 主要数据表
|
||||||
|
|
||||||
| 表 | 用途 |
|
| 表 | 用途 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `operation_groups` | 运营组及启停状态 |
|
| `operation_groups` | 运营组及状态 |
|
||||||
| `users` / `sessions` | 工作台账号、角色和登录会话 |
|
| `users` / `sessions` | 工作台账号、角色和会话 |
|
||||||
| `customer_sessions` | 客户项目级验收会话 |
|
| `customer_sessions` | 项目级客户会话 |
|
||||||
| `projects` | 项目、客户访问密码和访问期限 |
|
| `projects` | 项目、客户访问配置和自动验收状态 |
|
||||||
| `collections` | 项目下的作品交付集 |
|
| `collections` | 迁移期内部兼容容器,不属于产品层级 |
|
||||||
| `notes` | 作品当前状态和当前版本 |
|
| `notes` | 作品当前状态、活动轮次和项目归属 |
|
||||||
| `work_versions` | 各版本标题、正文、标签和状态快照 |
|
| `review_rounds` | 验收轮次与完成原因 |
|
||||||
| `images` | 版本图片、顺序、存储提供方和对象 Key |
|
| `work_versions` | 单轮内容快照;每轮恰好一条 |
|
||||||
|
| `images` | 轮次图片、顺序和存储信息 |
|
||||||
| `annotations` | 图片坐标批注 |
|
| `annotations` | 图片坐标批注 |
|
||||||
| `text_annotations` | 标题或正文的版本级批注 |
|
| `text_annotations` | 标题、正文和 Tag 选区批注与文本上下文 |
|
||||||
| `work_comments` | 作品总体反馈与回复 |
|
| `work_comments` | 作品总体反馈 |
|
||||||
| `review_events` | 提交、修改、通过、重新打开等验收记录 |
|
| `review_events` | 提交、退修、通过和重新打开记录 |
|
||||||
| `api_keys` | 平台级或项目级 API Key 的哈希与状态 |
|
| `api_keys` | 平台级/项目级 API Key 哈希 |
|
||||||
| `storage_configs` | 加密后的 COS 配置及启用状态 |
|
| `storage_configs` | 加密后的 COS 配置 |
|
||||||
| `audit_logs` | 管理和业务操作审计 |
|
| `audit_logs` | 管理与业务审计 |
|
||||||
|
|
||||||
## 存储流程
|
## 状态计算
|
||||||
|
|
||||||
平台管理员在管理页新增 COS 配置。SecretId 和 SecretKey 使用 `COS_CONFIG_ENCRYPTION_KEY` 派生的 AES-256-GCM 密钥加密后写入数据库,读取配置的接口不会返回明文。
|
作品状态为 `draft`、`pending`、`changes_requested`、`approved`。只有活动轮次可新增批注和作出验收决定;新轮次会锁定旧轮次。客户通过活动轮次后作品为已通过,退修后运营通过新轮次提交修改。
|
||||||
|
|
||||||
启用配置前会在目标桶的 `.delivery-desk-check/` 路径依次上传、读取并删除一个临时对象。启用后,新上传文件写入:
|
项目验收状态自动计算:
|
||||||
|
|
||||||
```text
|
- 没有非草稿作品:`draft`
|
||||||
<path-prefix>/originals/YYYY/MM/<uuid>.<ext>
|
- 存在未通过作品:`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 导入;全部图片准备成功后才创建作品或新验收轮次。该规则不自动追溯迁移历史图片内容。
|
||||||
|
|||||||
@@ -3,34 +3,34 @@
|
|||||||
## 已完成
|
## 已完成
|
||||||
|
|
||||||
- 三类工作台角色、运营组隔离、账号管理和 7 天会话
|
- 三类工作台角色、运营组隔离、账号管理和 7 天会话
|
||||||
- 项目、作品交付集、作品、版本和验收状态
|
- 项目 → 作品 → 单方案验收轮次,以及项目级自动验收状态
|
||||||
- 手动多图上传、封面、上传前拖拽排序及新版本
|
- 手动多图上传、公开 URL API、封面与上传前拖拽排序
|
||||||
- 图片坐标批注、标题/正文批注、总体反馈和验收记录
|
- 缩略图只读标记、悬浮图片窗格、原图缩放与坐标批注
|
||||||
- 客户项目链接、密码、姓名、期限和验收决定
|
- 标题、正文和 Tag 选区批注、总体反馈、按作品聚合反馈和验收记录
|
||||||
- API Key、审计日志、COS 前端配置及连接测试
|
- 批注回复线程、只能撤回本人反馈并保留撤回记录
|
||||||
|
- 客户项目链接、密码、姓名、访问期限和验收决定
|
||||||
|
- API Key、审计日志、COS 前端配置、连接测试、私有地址拦截及外部 URL 安全转存
|
||||||
|
- 内置 Agent 安全上传 Skill、双阶段确认脚本、回归测试和可分发 ZIP
|
||||||
- SQLite/PostgreSQL 双运行时、迁移验证和 Docker 部署
|
- SQLite/PostgreSQL 双运行时、迁移验证和 Docker 部署
|
||||||
- 桌面端与移动端响应式页面
|
- 桌面端与移动端响应式页面
|
||||||
|
|
||||||
## 初版上线前仍需完成
|
## 初版上线前仍需完成
|
||||||
|
|
||||||
以下需求尚未在代码中完整落地,不应在交付时宣称可用:
|
以下范围尚未完整落地,不应在交付时宣称可用:
|
||||||
|
|
||||||
- ZIP + CSV 批量导入和最多 100 个作品的异步批量 API
|
- ZIP + CSV 批量导入和最多 100 个作品的异步批量 API
|
||||||
- `externalId` 幂等创建作品(项目和作品交付集暂未支持)
|
|
||||||
- webhook 与站内未读通知
|
- webhook 与站内未读通知
|
||||||
- PDF 验收报告和最终原图 ZIP 导出
|
- PDF 验收报告和最终原图 ZIP 导出
|
||||||
- 批注/回复的参考图片附件
|
- 批注/回复中的参考图片附件
|
||||||
- 项目、作品交付集、作品的回收站、归档恢复和永久删除规则
|
- 项目与作品的回收站、归档恢复和永久删除流程
|
||||||
- 已上传作品在所有阶段的图片重新排序
|
- 已上传作品在所有阶段的图片重新排序
|
||||||
- 在线人员状态、实时变更通知和并发版本冲突保护
|
- 在线人员状态、实时变更通知和并发冲突保护
|
||||||
- HEIC/HEIF 转换、缩略图流水线和 EXIF 定位信息清理
|
- HEIC/HEIF 转换、多尺寸缩略图和 EXIF 定位信息清理
|
||||||
- 自动化端到端浏览器测试及真实腾讯云、PostgreSQL 部署演练
|
- 真实腾讯云、生产 PostgreSQL、HTTPS 和备份恢复演练
|
||||||
|
|
||||||
## 上线门槛
|
## 上线门槛
|
||||||
|
|
||||||
初版正式发布至少应满足:
|
1. 使用 PostgreSQL 和独立生产 COS 桶,完成备份恢复演练。
|
||||||
|
2. 轮换所有在聊天、截图或开发数据中出现过的云密钥和临时密码。
|
||||||
1. 使用 PostgreSQL 和独立生产 COS 桶,完成一次备份恢复演练。
|
|
||||||
2. 轮换所有在聊天、截图或开发数据库中出现过的云密钥和临时密码。
|
|
||||||
3. 在 HTTPS 域名下验证平台管理员、组管理员、光影叙事和客户四条核心流程。
|
3. 在 HTTPS 域名下验证平台管理员、组管理员、光影叙事和客户四条核心流程。
|
||||||
4. 根据真实交付承诺,从上方未完成清单中选定必须进入初版的项目。
|
4. 根据真实交付承诺,从未完成清单中选定必须进入初版的项目。
|
||||||
|
|||||||
@@ -1,58 +1,44 @@
|
|||||||
# API 接入指南
|
# API 接入指南
|
||||||
|
|
||||||
## 认证方式
|
## 认证
|
||||||
|
|
||||||
工作台网页使用 HttpOnly Cookie 会话。外部客户端使用:
|
工作台使用 HttpOnly Cookie;外部客户端使用:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
Authorization: Bearer dd_live_xxx
|
Authorization: Bearer dd_live_xxx
|
||||||
```
|
```
|
||||||
|
|
||||||
API Key 明文只在创建时返回一次,数据库仅保存 SHA-256 哈希。平台管理员创建平台级 Key;组管理员创建本组项目级 Key。失效或越权请求会返回 `401` 或 `403`。
|
平台级 Key 可跨组创建和查询项目。项目级 Key 只能操作绑定项目,包括在该项目中新建作品和验收轮次。密钥明文只在创建时返回一次。
|
||||||
|
|
||||||
## 主要路由
|
## Agent 安全上传 Skill
|
||||||
|
|
||||||
| 路由组 | 用途 |
|
项目内置 `.agents/skills/upload-delivery-desk-work`,用于引导 Agent 精确定位运营组、项目和作品后创建作品或提交新验收轮次。它强制执行“发现 → 生成计划 → 操作者确认 → 单次提交 → 回读验证”,不允许根据名称猜测目标。
|
||||||
|---|---|
|
|
||||||
| `/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` | 数据库就绪检查 |
|
|
||||||
|
|
||||||
## 查询运营组、项目、作品交付集和作品
|
更新 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
|
```bash
|
||||||
# 返回 Key 有权访问的项目,响应包含 group_id、group_name 和项目 id
|
# 查询 Key 可访问的项目,响应包含 group_id、group_name 和项目 id
|
||||||
curl http://localhost:3010/api/projects \
|
curl http://localhost:3010/api/projects \
|
||||||
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
|
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
|
||||||
|
|
||||||
# 查询项目中的作品交付集
|
# 查询项目作品
|
||||||
curl http://localhost:3010/api/projects/1/collections \
|
curl http://localhost:3010/api/projects/1/works \
|
||||||
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
|
|
||||||
|
|
||||||
# 查询交付集中的作品,响应包含作品 id、external_id 和 version_number
|
|
||||||
curl "http://localhost:3010/api/notes?collectionId=1" \
|
|
||||||
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
|
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
|
||||||
```
|
```
|
||||||
|
|
||||||
项目级 Key 的项目列表只会返回绑定项目;平台级 Key 可以查询全部运营组的项目。创建作品时只传 `collectionId`,服务会据此确定项目和运营组并校验权限,不需要重复传递 `projectId` 或 `groupId`。
|
调用方不再需要作品交付集 ID。`externalId` 在项目内唯一,可用于安全重试和找回作品。
|
||||||
|
|
||||||
## 创建项目
|
## 创建项目
|
||||||
|
|
||||||
平台级 API Key 可以指定目标运营组。项目级 Key 不能创建项目。
|
只有平台级 Key 可以创建项目。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:3010/api/projects \
|
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":"客户可见说明"}'
|
-d '{"name":"7 月内容计划","slug":"july-content","groupId":1,"client_description":"客户可见说明"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
`slug` 仅支持小写字母、数字和连字符,并作为客户验收链接的一部分。
|
## 创建作品
|
||||||
|
|
||||||
## 创建作品交付集
|
JSON 请求中的 `images` 为 1–30 个公开 HTTP/HTTPS URL,且平台必须已有活动 COS 配置。数组顺序就是展示顺序,第一张为封面。
|
||||||
|
|
||||||
|
- 所有 URL 都会先拒绝 `localhost`、本机、私有网段、局域网和保留地址;即使域名与配置同源也不会绕过这项检查。
|
||||||
|
- URL 与活动 COS 的公开域名或 CDN 域名同源时直接保存,不重复上传。
|
||||||
|
- 其他域名的图片会由服务端下载并转存到活动 COS,最终入库 URL 来自该 COS。
|
||||||
|
- 外部图片单张不得超过 20 MB,必须返回受支持的图片类型;本机、内网、保留地址和非标准端口会被拒绝。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:3010/api/projects/1/collections \
|
curl -X POST http://localhost:3010/api/projects/1/works \
|
||||||
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"name":"2026 年 7 月交付","client_description":"本月交付内容"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 上传作品
|
|
||||||
|
|
||||||
外部客户端使用 JSON 创建作品,`images` 直接传入 1–30 个公开可读的 HTTP/HTTPS 图片 URL。服务只保存 URL,不会下载图片或再次上传到 COS。数组顺序就是展示顺序,第一张为封面。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3010/api/notes \
|
|
||||||
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
|
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"collectionId": 1,
|
"externalId":"client-work-20260722-001",
|
||||||
"externalId": "client-work-20260721-001",
|
|
||||||
"title":"作品标题",
|
"title":"作品标题",
|
||||||
"description":"正文内容",
|
"description":"正文内容",
|
||||||
"tags": ["用户填写的标签原文"],
|
"tags":["#夏日","用户原文"],
|
||||||
"images": [
|
"images":["https://cdn.example.com/01.jpg","https://cdn.example.com/02.jpg"]
|
||||||
"https://cdn.example.com/works/01.jpg",
|
|
||||||
"https://cdn.example.com/works/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
|
```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 "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"title":"修改后的标题",
|
"title":"修改后的标题",
|
||||||
"description":"修改后的正文",
|
"description":"修改后的正文",
|
||||||
"tags": ["修改后的标签原文"],
|
"tags":["#第二轮"],
|
||||||
"images": [
|
"images":["https://cdn.example.com/round-2.jpg"]
|
||||||
"https://cdn.example.com/works/v2-01.jpg",
|
|
||||||
"https://cdn.example.com/works/v2-02.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:
|
# 按轮返回该作品全部反馈和验收事件
|
||||||
|
curl http://localhost:3010/api/works/12/annotations \
|
||||||
```powershell
|
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
|
||||||
$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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
如果项目级 Key 只能访问一个项目,并且项目下只有一个作品交付集,可以省略两个 ID。脚本也支持不传 Key、改用 `--username` 后交互输入密码。
|
标题、正文和 Tag 选区批注使用:
|
||||||
|
|
||||||
## 错误响应
|
|
||||||
|
|
||||||
错误统一以 JSON 返回:
|
|
||||||
|
|
||||||
```json
|
```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 失败。
|
||||||
|
|||||||
@@ -47,7 +47,22 @@ docker compose logs --tail=100 app
|
|||||||
|
|
||||||
连接测试会真实执行一次上传、读取和删除,因此密钥至少需要目标前缀的这三项权限。测试对象会尽力清理;请求中断时可检查 `<path-prefix>/.delivery-desk-check/` 是否残留临时文件。
|
连接测试会真实执行一次上传、读取和删除,因此密钥至少需要目标前缀的这三项权限。测试对象会尽力清理;请求中断时可检查 `<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 check
|
||||||
pnpm lint
|
pnpm lint
|
||||||
pnpm build
|
pnpm build
|
||||||
|
pnpm test:review-rounds
|
||||||
|
pnpm test:collection-status
|
||||||
pnpm test:postgres-runtime
|
pnpm test:postgres-runtime
|
||||||
|
pnpm db:postgres:validate
|
||||||
|
python tests/test_upload_skill.py
|
||||||
```
|
```
|
||||||
|
|
||||||
正式切换前还应验证:管理员首次改密、客户访问门禁、COS 上传、客户批注与验收、数据库备份及 HTTPS Cookie。
|
正式切换前还应验证:管理员首次改密、客户访问门禁、COS 上传、客户批注与验收、数据库备份及 HTTPS Cookie。
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"db:postgres:migrate": "tsx scripts/migrate-sqlite-to-postgres.ts",
|
"db:postgres:migrate": "tsx scripts/migrate-sqlite-to-postgres.ts",
|
||||||
"db:postgres:validate": "tsx scripts/validate-postgres-migration.ts",
|
"db:postgres:validate": "tsx scripts/validate-postgres-migration.ts",
|
||||||
"test:postgres-runtime": "tsx scripts/test-postgres-runtime.ts",
|
"test:postgres-runtime": "tsx scripts/test-postgres-runtime.ts",
|
||||||
|
"test:collection-status": "tsx scripts/test-sqlite-collection-status.ts",
|
||||||
|
"test:review-rounds": "tsx scripts/test-sqlite-review-rounds.ts",
|
||||||
"dev": "concurrently \"npm run client:dev\" \"npm run server:dev\""
|
"dev": "concurrently \"npm run client:dev\" \"npm run server:dev\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ if (!fs.existsSync(sqlitePath)) throw new Error(`SQLite 数据库不存在:${s
|
|||||||
|
|
||||||
const tables = [
|
const tables = [
|
||||||
'operation_groups', 'users', 'projects', 'collections', 'notes', 'images', 'annotations',
|
'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',
|
'audit_logs', 'api_keys', 'storage_configs',
|
||||||
] as const;
|
] as const;
|
||||||
const booleanColumns: Record<string, Set<string>> = {
|
const booleanColumns: Record<string, Set<string>> = {
|
||||||
@@ -26,10 +26,12 @@ const client = new pg.Client({ connectionString: databaseUrl, ssl: process.env.P
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
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);
|
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');
|
if (existing && !replace) throw new Error('PostgreSQL 已有数据。确认覆盖时请显式添加 --replace');
|
||||||
await client.query('BEGIN');
|
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`);
|
if (replace) await client.query(`TRUNCATE ${[...tables].reverse().map((table) => `"${table}"`).join(', ')} RESTART IDENTITY CASCADE`);
|
||||||
|
|
||||||
for (const table of tables) {
|
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)`);
|
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');
|
await client.query('COMMIT');
|
||||||
process.stdout.write(`迁移完成:${tables.length} 张表已从 ${sqlitePath} 导入 PostgreSQL\n`);
|
process.stdout.write(`迁移完成:${tables.length} 张表已从 ${sqlitePath} 导入 PostgreSQL\n`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
57
scripts/package-upload-skill.ps1
Normal file
57
scripts/package-upload-skill.ps1
Normal 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
|
||||||
@@ -4,7 +4,8 @@ process.env.INITIAL_ADMIN_PASSWORD = 'AdminTest123!';
|
|||||||
process.env.COS_CONFIG_ENCRYPTION_KEY = 'test-only-encryption-key-at-least-32-characters';
|
process.env.COS_CONFIG_ENCRYPTION_KEY = 'test-only-encryption-key-at-least-32-characters';
|
||||||
|
|
||||||
const { default: app } = await import('../api/app.js');
|
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');
|
const server = app.listen(0, '127.0.0.1');
|
||||||
await new Promise<void>((resolve) => server.once('listening', resolve));
|
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||||||
const address = server.address();
|
const address = server.address();
|
||||||
@@ -28,7 +29,6 @@ try {
|
|||||||
expectStatus(login.response.status, 200, '平台管理员登录');
|
expectStatus(login.response.status, 200, '平台管理员登录');
|
||||||
const adminCookie = login.response.headers.get('set-cookie')?.split(';')[0];
|
const adminCookie = login.response.headers.get('set-cookie')?.split(';')[0];
|
||||||
if (!adminCookie) throw new Error('登录未返回会话 Cookie');
|
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);
|
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, '创建运营组');
|
expectStatus(group.response.status, 201, '创建运营组');
|
||||||
const groupId = Number((group.body as { id: number }).id);
|
const groupId = Number((group.body as { id: number }).id);
|
||||||
@@ -76,9 +76,47 @@ try {
|
|||||||
expectStatus(project.response.status, 201, '创建项目', project.body);
|
expectStatus(project.response.status, 201, '创建项目', project.body);
|
||||||
if((project.body as {group_name?:string}).group_name!=='已更名运营组')throw new Error('项目接口未返回所属运营组名称');
|
if((project.body as {group_name?:string}).group_name!=='已更名运营组')throw new Error('项目接口未返回所属运营组名称');
|
||||||
const projectId = Number((project.body as { id: number }).id);
|
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);
|
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);
|
expectStatus(otherProject.response.status,201,'创建同组隔离项目',otherProject.body);
|
||||||
const otherProjectId=Number((otherProject.body as {id:number}).id);
|
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);
|
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, '配置客户访问');
|
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);
|
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, '创建作品交付集');
|
expectStatus(collection.response.status, 201, '创建作品交付集');
|
||||||
const collectionId=Number((collection.body as {id:number}).id);
|
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);
|
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);
|
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 未严格隔离到绑定项目');
|
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});
|
const forbiddenProject=await request(`/api/projects/${otherProjectId}`,{headers:bearerHeaders});
|
||||||
expectStatus(forbiddenProject.response.status,403,'项目级 Key 拒绝访问其他项目',forbiddenProject.body);
|
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});
|
const visibleCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||||||
expectStatus(visibleCollections.response.status,200,'项目级 Key 查询作品交付集',visibleCollections.body);
|
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 未返回目标作品交付集');
|
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)});
|
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);
|
expectStatus(createdWork.response.status,201,'JSON URL 创建作品',createdWork.body);
|
||||||
const workId=Number((createdWork.body as {id:number}).id);
|
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)});
|
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);
|
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 重复请求创建了不同作品');
|
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('作品版本号未递增');
|
if(Number((newVersion.body as {version_number:number}).version_number)!==2)throw new Error('作品版本号未递增');
|
||||||
const workDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
|
const workDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
|
||||||
expectStatus(workDetail.response.status,200,'读取新版本作品',workDetail.body);
|
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;
|
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 保存');
|
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 revokeProjectKey=await request(`/api/management/api-keys/${projectKeyId}`,{method:'DELETE'},newAdminCookie);
|
|
||||||
expectStatus(revokeProjectKey.response.status,204,'吊销项目级 API Key');
|
|
||||||
|
|
||||||
const reviewLogin = await request('/api/review/postgres-runtime-test/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer_name: '客户测试', password: 'Review123!' }) });
|
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, '客户登录');
|
expectStatus(reviewLogin.response.status, 200, '客户登录');
|
||||||
const reviewCookie = reviewLogin.response.headers.get('set-cookie')?.split(';')[0];
|
const reviewCookie = reviewLogin.response.headers.get('set-cookie')?.split(';')[0];
|
||||||
const reviewProject = await request('/api/review/postgres-runtime-test/project', {}, reviewCookie);
|
const reviewProject = await request('/api/review/postgres-runtime-test/project', {}, reviewCookie);
|
||||||
expectStatus(reviewProject.response.status, 200, '客户项目读取');
|
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);
|
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');
|
expectStatus(apiKey.response.status, 201, '创建平台 API Key');
|
||||||
const keyId = Number((apiKey.body as { item: { id: number } }).item.id);
|
const keyId = Number((apiKey.body as { item: { id: number } }).item.id);
|
||||||
const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie);
|
const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie);
|
||||||
expectStatus(revoke.response.status, 204, '吊销平台 API Key');
|
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 {
|
} finally {
|
||||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||||
await closeDatabase();
|
await closeDatabase();
|
||||||
|
|||||||
63
scripts/test-sqlite-collection-status.ts
Normal file
63
scripts/test-sqlite-collection-status.ts
Normal 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();
|
||||||
|
}
|
||||||
68
scripts/test-sqlite-review-rounds.ts
Normal file
68
scripts/test-sqlite-review-rounds.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { newDb } from 'pg-mem';
|
|||||||
|
|
||||||
const tables = [
|
const tables = [
|
||||||
'operation_groups', 'users', 'projects', 'collections', 'notes', 'images', 'annotations',
|
'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',
|
'audit_logs', 'api_keys', 'storage_configs',
|
||||||
] as const;
|
] as const;
|
||||||
const booleanColumns: Record<string, Set<string>> = {
|
const booleanColumns: Record<string, Set<string>> = {
|
||||||
@@ -19,9 +19,16 @@ const sqlite = new Database(path.resolve('data/app.db'), { readonly: true });
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
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(/^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);
|
await client.query(schema);
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
const exists = sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table);
|
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];
|
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('作品版本关系未正确迁移');
|
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);
|
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('活动对象存储配置超过一个');
|
if (activeStorage > 1) throw new Error('活动对象存储配置超过一个');
|
||||||
process.stdout.write(`PostgreSQL schema 与迁移映射验证通过:${tables.length} 张表,当前作品 V${current.version_number},存储=${current.storage_provider}\n`);
|
process.stdout.write(`PostgreSQL schema 与迁移映射验证通过:${tables.length} 张表,当前作品 V${current.version_number},存储=${current.storage_provider}\n`);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export type ReviewStatus = 'draft' | 'pending' | 'changes_requested' | 'approved';
|
export type ReviewStatus = 'draft' | 'pending' | 'changes_requested' | 'approved';
|
||||||
|
export type ReviewRoundStatus = 'draft' | 'reviewing' | 'completed';
|
||||||
export type CollectionStatus = 'draft' | 'reviewing' | 'completed' | 'archived';
|
export type CollectionStatus = 'draft' | 'reviewing' | 'completed' | 'archived';
|
||||||
|
export type ProjectReviewStatus = 'draft' | 'reviewing' | 'completed' | 'archived';
|
||||||
export type UserRole = 'platform_admin' | 'group_admin' | 'operator';
|
export type UserRole = 'platform_admin' | 'group_admin' | 'operator';
|
||||||
|
|
||||||
export interface CurrentUser {
|
export interface CurrentUser {
|
||||||
@@ -94,11 +96,16 @@ export interface Project {
|
|||||||
slug: string;
|
slug: string;
|
||||||
client_description: string;
|
client_description: string;
|
||||||
status: 'active' | 'closed' | 'archived';
|
status: 'active' | 'closed' | 'archived';
|
||||||
|
review_status: ProjectReviewStatus;
|
||||||
|
review_completed_at: string | null;
|
||||||
customer_access_enabled: boolean;
|
customer_access_enabled: boolean;
|
||||||
access_expires_at: string | null;
|
access_expires_at: string | null;
|
||||||
has_access_password: boolean;
|
has_access_password: boolean;
|
||||||
collection_count: number;
|
collection_count: number;
|
||||||
work_count: number;
|
work_count: number;
|
||||||
|
pending_count: number;
|
||||||
|
changes_requested_count: number;
|
||||||
|
approved_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,11 +126,13 @@ export interface WorkCollection {
|
|||||||
status: CollectionStatus;
|
status: CollectionStatus;
|
||||||
work_count: number;
|
work_count: number;
|
||||||
approved_count: number;
|
approved_count: number;
|
||||||
|
completed_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: number;
|
id: number;
|
||||||
|
project_id: number;
|
||||||
collection_id: number;
|
collection_id: number;
|
||||||
external_id: string | null;
|
external_id: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -131,6 +140,8 @@ export interface Note {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
review_status: ReviewStatus;
|
review_status: ReviewStatus;
|
||||||
version_number: number;
|
version_number: number;
|
||||||
|
active_round_id: number | null;
|
||||||
|
approved_version_number: number | null;
|
||||||
cover_image: string;
|
cover_image: string;
|
||||||
image_count: number;
|
image_count: number;
|
||||||
annotation_count: number;
|
annotation_count: number;
|
||||||
@@ -156,17 +167,23 @@ export interface Annotation {
|
|||||||
y: number;
|
y: number;
|
||||||
content: string;
|
content: string;
|
||||||
author_name: string;
|
author_name: string;
|
||||||
|
author_role: 'client' | 'operator';
|
||||||
status: 'open' | 'resolved' | 'confirmed';
|
status: 'open' | 'resolved' | 'confirmed';
|
||||||
|
closure_reason: string;
|
||||||
|
withdrawn_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkComment {
|
export interface WorkComment {
|
||||||
id: number;
|
id: number;
|
||||||
note_id: number;
|
note_id: number;
|
||||||
|
version_number: number;
|
||||||
content: string;
|
content: string;
|
||||||
author_name: string;
|
author_name: string;
|
||||||
author_role: 'client' | 'operator';
|
author_role: 'client' | 'operator';
|
||||||
status: 'open' | 'resolved' | 'confirmed';
|
status: 'open' | 'resolved' | 'confirmed';
|
||||||
|
closure_reason: string;
|
||||||
|
withdrawn_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,10 +191,33 @@ export interface TextAnnotation {
|
|||||||
id: number;
|
id: number;
|
||||||
note_id: number;
|
note_id: number;
|
||||||
version_number: 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;
|
content: string;
|
||||||
author_name: string;
|
author_name: string;
|
||||||
|
author_role: 'client' | 'operator';
|
||||||
status: 'open' | 'resolved' | 'confirmed';
|
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;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,14 +229,18 @@ export interface NoteDetail extends Note {
|
|||||||
images: ImageWithAnnotations[];
|
images: ImageWithAnnotations[];
|
||||||
text_annotations: TextAnnotation[];
|
text_annotations: TextAnnotation[];
|
||||||
comments: WorkComment[];
|
comments: WorkComment[];
|
||||||
project: Pick<Project, 'id' | 'name' | 'slug'>;
|
feedback_replies: FeedbackReply[];
|
||||||
collection: Pick<WorkCollection, 'id' | 'name'>;
|
project: Pick<Project, 'id' | 'name' | 'slug' | 'status' | 'review_status'>;
|
||||||
versions: WorkVersion[];
|
rounds: WorkRound[];
|
||||||
review_events: ReviewEvent[];
|
review_events: ReviewEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkVersion {
|
export interface WorkRound {
|
||||||
version_number: number;
|
version_number: number;
|
||||||
|
review_round_id: number;
|
||||||
|
round_number: number;
|
||||||
|
round_status: ReviewRoundStatus;
|
||||||
|
completion_reason: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -204,6 +248,17 @@ export interface WorkVersion {
|
|||||||
created_at: string;
|
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 {
|
export interface ReviewEvent {
|
||||||
id: number;
|
id: number;
|
||||||
version_number: number;
|
version_number: number;
|
||||||
@@ -221,6 +276,20 @@ export interface CreateAnnotationRequest {
|
|||||||
y: number;
|
y: number;
|
||||||
content: string;
|
content: string;
|
||||||
author_name?: 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 {
|
export interface NoteListQuery {
|
||||||
|
|||||||
BIN
skill-packages/upload-delivery-desk-work.zip
Normal file
BIN
skill-packages/upload-delivery-desk-work.zip
Normal file
Binary file not shown.
@@ -39,6 +39,7 @@ function AppContent() {
|
|||||||
<Route path="/review/:slug/collections/:collectionId" element={<CustomerReviewPage />} />
|
<Route path="/review/:slug/collections/:collectionId" element={<CustomerReviewPage />} />
|
||||||
<Route path="/review/:slug/works/:noteId" element={<CustomerReviewPage />} />
|
<Route path="/review/:slug/works/:noteId" element={<CustomerReviewPage />} />
|
||||||
<Route path="/works/:noteId/new-version" element={<ProtectedRoute><NewVersionPage /></ProtectedRoute>} />
|
<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="/projects/:projectId/collections/:collectionId/upload" element={<ProtectedRoute><UploadPage /></ProtectedRoute>} />
|
||||||
<Route path="/upload" element={<Navigate to="/" replace />} />
|
<Route path="/upload" element={<Navigate to="/" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -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 {
|
export class ApiError extends Error {
|
||||||
constructor(public status: number, message: string) { super(message); this.name = 'ApiError'; }
|
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) }),
|
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) }),
|
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) }),
|
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 = {}) => {
|
listNotes: (query: NoteListQuery = {}) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
Object.entries(query).forEach(([key, value]) => value != null && params.set(key, String(value)));
|
Object.entries(query).forEach(([key, value]) => value != null && params.set(key, String(value)));
|
||||||
return request<Note[]>(`/api/notes?${params}`);
|
return request<Note[]>(`/api/notes?${params}`);
|
||||||
},
|
},
|
||||||
getNote: (id: number, version?: number) => request<NoteDetail>(`/api/notes/${id}${version ? `?version=${version}` : ''}`),
|
listProjectWorks: (projectId: number, query: NoteListQuery = {}) => {
|
||||||
createNote: (payload: { collectionId: number; title: string; description: string; tags: string[]; images: File[] }) => {
|
const params = new URLSearchParams();
|
||||||
const form = new FormData();
|
Object.entries(query).forEach(([key, value]) => value != null && params.set(key, String(value)));
|
||||||
form.append('collectionId', String(payload.collectionId));
|
return request<Note[]>(`/api/projects/${projectId}/works?${params}`);
|
||||||
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 });
|
|
||||||
},
|
},
|
||||||
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();
|
const form = new FormData();
|
||||||
form.append('title', payload.title); form.append('description', payload.description); form.append('tags', payload.tags.join(','));
|
form.append('title', payload.title); form.append('description', payload.description); form.append('tags', payload.tags.join(','));
|
||||||
payload.images.forEach((file) => form.append('images', file));
|
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 }) }),
|
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 }) }),
|
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) }),
|
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' }),
|
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) }),
|
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`),
|
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) }),
|
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`),
|
getCustomerProject: (slug: string) => request<{ project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status' | 'review_status'>; works: Note[]; reviewer_name: string }>(`/api/review/${slug}/project`),
|
||||||
getCustomerCollection: (slug: string, collectionId: number) => request<{ collection: WorkCollection; works: Note[] }>(`/api/review/${slug}/collections/${collectionId}/works`),
|
getCustomerWorkRound: (slug: string, workId: number, round?: number) => request<NoteDetail>(`/api/review/${slug}/works/${workId}${round ? `?round=${round}` : ''}`),
|
||||||
getCustomerWork: (slug: string, noteId: number, version?: number) => request<NoteDetail>(`/api/review/${slug}/works/${noteId}${version ? `?version=${version}` : ''}`),
|
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 }) }),
|
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) }),
|
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) }),
|
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) }),
|
||||||
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 }) }),
|
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 }) }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Check, MessageCircle, X } from 'lucide-react';
|
import { Check, MessageCircle, X } from 'lucide-react';
|
||||||
import type { Annotation, NoteImage } from '@shared/types';
|
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('');
|
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 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('')};
|
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>)}
|
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 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>}
|
{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 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>
|
{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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,44 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { Check, MessageCircle, Plus, X } from 'lucide-react';
|
|
||||||
import type { TextAnnotation } from '@shared/types';
|
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 }) {
|
export type TextSelectionDraft = { start: number; end: number; text: string };
|
||||||
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);
|
|
||||||
|
|
||||||
const submit = async () => {
|
export default function AnnotatableText({ text, annotations, onSelect, onOpenAnnotation, readOnly = false, variant = 'body' }: {
|
||||||
if (!text.trim() || busy) return;
|
text: string;
|
||||||
setBusy(true);
|
label: string;
|
||||||
try { await onAdd(text.trim()); setText(''); setAdding(false); }
|
annotations: TextAnnotation[];
|
||||||
finally { setBusy(false); }
|
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">
|
const segments = boundaries.slice(0, -1).map((start, index) => {
|
||||||
{children}
|
const end = boundaries[index + 1];
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
const matches = visibleAnnotations.filter((item) => item.start_offset <= start && item.end_offset >= end);
|
||||||
<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>
|
return { start, value: text.slice(start, end), matches };
|
||||||
{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>
|
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>)}</>;
|
||||||
{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>}
|
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>;
|
||||||
</div>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
77
src/components/CollaborationDrawer.tsx
Normal file
77
src/components/CollaborationDrawer.tsx
Normal 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>;
|
||||||
|
}
|
||||||
19
src/components/CollectionStatusBadge.tsx
Normal file
19
src/components/CollectionStatusBadge.tsx
Normal 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>;
|
||||||
|
}
|
||||||
62
src/components/ImageReviewModal.tsx
Normal file
62
src/components/ImageReviewModal.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -1,21 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { Navigate, useParams } from 'react-router-dom';
|
||||||
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';
|
|
||||||
|
|
||||||
export default function CollectionPage() {
|
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 { projectId } = useParams();
|
||||||
const [editing,setEditing]=useState(false); const [editName,setEditName]=useState(''); const [editDesc,setEditDesc]=useState('');
|
return <Navigate to={`/projects/${projectId}`} replace />;
|
||||||
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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,68 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
import { Link, Navigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { ArrowLeft, ArrowRight, Check, KeyRound, MessageSquareText, Send } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, Check, KeyRound } from 'lucide-react';
|
||||||
import type { CustomerAccessState, Note, NoteDetail, Project, WorkCollection } from '@shared/types';
|
import type { CustomerAccessState, Note, NoteDetail, Project } from '@shared/types';
|
||||||
import { api, ApiError } from '@/api/client';
|
import { api, ApiError } from '@/api/client';
|
||||||
import AnnotatableImage from '@/components/AnnotatableImage';
|
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 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() {
|
export default function CustomerReviewPage() {
|
||||||
const { slug = '', collectionId, noteId } = useParams();
|
const { slug = '', collectionId, noteId } = useParams();
|
||||||
const [search] = useSearchParams();
|
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 [access, setAccess] = useState<CustomerAccessState | null>(null);
|
||||||
const [projectData, setProjectData] = useState<ProjectPayload | 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 [work, setWork] = useState<NoteDetail | null>(null);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState(''); const [password, setPassword] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [error, setError] = useState(''); const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
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]);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => { void load(); }, [load]);
|
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) => {
|
if (collectionId) return <Navigate to={`/review/${slug}`} replace/>;
|
||||||
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 (!access && !error) return <Centered text="正在打开验收空间…"/>;
|
if (!access && !error) return <Centered text="正在打开验收空间…"/>;
|
||||||
if (!access) return <Centered text={error || '项目不存在'}/>;
|
if (!access) return <Centered text={error || '项目不存在'}/>;
|
||||||
if (!access.enabled || access.expired) return <Centered text={access.expired ? '此项目的访问链接已到期' : '此项目暂未开放客户访问'}/>;
|
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 (!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 (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 || '正在加载项目…'}/>;
|
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 }) {
|
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={(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>;
|
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 }) {
|
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>;
|
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 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>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkReview({ slug, work, reviewer, reload }: { slug: string; work: NoteDetail; reviewer: string; reload: () => Promise<void> }) {
|
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 [drawerOpen, setDrawerOpen] = useState(false); const [reason, setReason] = useState(''); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState('');
|
||||||
const send=async()=>{if(!comment.trim())return;setBusy(true);await api.addCustomerComment(slug,work.id,comment.trim());setComment('');await reload();setBusy(false)};
|
const [selectedImage, setSelectedImage] = useState<{ imageId: number } | null>(null);
|
||||||
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)};
|
const [focusImageId, setFocusImageId] = useState<number>();
|
||||||
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 [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 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 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>; }
|
||||||
|
|||||||
@@ -6,29 +6,68 @@ import { api } from '@/api/client';
|
|||||||
import { useAuthStore } from '@/store/useAuthStore';
|
import { useAuthStore } from '@/store/useAuthStore';
|
||||||
|
|
||||||
export default function Dashboard() {
|
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 [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [creating,setCreating]=useState(false); const [editingGroup,setEditingGroup]=useState(false); const [groupName,setGroupName]=useState(''); const [form,setForm]=useState({name:'',slug:'',client_description:'',groupId:''});
|
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 { user, initialize } = useAuthStore();
|
||||||
|
|
||||||
const load = () => api.listProjects().then(setProjects);
|
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]);
|
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 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 metrics = useMemo(() => [
|
||||||
const create=async()=>{await api.createProject({...form,groupId:form.groupId?Number(form.groupId):undefined});setCreating(false);setForm({name:'',slug:'',client_description:'',groupId:''});await load()};
|
{ 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">
|
return <main className="mx-auto max-w-[1500px] px-5 py-10 lg:px-10 lg:py-14">
|
||||||
{user?.role === 'platform_admin' ? <>
|
{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="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-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="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>
|
<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} />
|
<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>}
|
{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={(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>}
|
{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>;
|
</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 ProjectGrid({ projects }: { projects: Project[] }) {
|
||||||
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>}
|
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 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 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>; }
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ const actionLabel: Record<string, string> = {
|
|||||||
'group.create': '创建运营组', 'group.active': '启用运营组', 'group.disabled': '停用运营组',
|
'group.create': '创建运营组', 'group.active': '启用运营组', 'group.disabled': '停用运营组',
|
||||||
'group.admin_replace':'更换组管理员','group.rename':'修改运营组名称','user.create': '创建账号', 'user.active': '启用账号', 'user.disabled': '停用账号', 'user.password_reset': '重置密码','user.name_update':'修改用户名',
|
'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',
|
'api_key.create': '创建 API Key', 'api_key.revoke': '吊销 API Key',
|
||||||
'project.create': '创建项目', 'project.update': '修改项目', 'collection.create': '创建作品交付集',
|
'project.create': '创建项目', 'project.update': '修改项目', 'collection.create': '初始化项目兼容容器',
|
||||||
'collection.update': '修改作品交付集', 'work.create': '上传作品',
|
'collection.update': '更新项目兼容容器', 'work.create': '上传作品',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ManagementPage() {
|
export default function ManagementPage() {
|
||||||
|
|||||||
@@ -1,19 +1,38 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
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 type { NoteDetail } from '@shared/types';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
|
|
||||||
type Item = { file: File; url: string };
|
type ImageItem = { file: File; url: string };
|
||||||
|
|
||||||
export default function NewVersionPage() {
|
export default function NewVersionPage() {
|
||||||
const id = Number(useParams().noteId); const navigate = useNavigate();
|
const id = Number(useParams().noteId);
|
||||||
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('');
|
const navigate = useNavigate();
|
||||||
useEffect(()=>{void api.getNote(id).then((item)=>{setWork(item);setTitle(item.title);setDescription(item.description);setTags(item.tags.join(', '))})},[id]);
|
const [work, setWork] = useState<NoteDetail | null>(null);
|
||||||
useEffect(()=>()=>items.forEach((item)=>URL.revokeObjectURL(item.url)),[items]);
|
const [title, setTitle] = useState('');
|
||||||
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 [description, setDescription] = useState('');
|
||||||
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 [tags, setTags] = useState('');
|
||||||
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)}};
|
const [images, setImages] = useState<ImageItem[]>([]);
|
||||||
if(!work)return null;
|
const [dragIndex, setDragIndex] = useState<number | null>(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">1–30 张,选择顺序即展示顺序</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 [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>; }
|
||||||
|
|||||||
@@ -1,65 +1,51 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
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 type { NoteDetail } from '@shared/types';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import AnnotatableImage from '@/components/AnnotatableImage';
|
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 StatusBadge from '@/components/StatusBadge';
|
||||||
import { useAuthStore } from '@/store/useAuthStore';
|
import { useAuthStore } from '@/store/useAuthStore';
|
||||||
|
|
||||||
export default function NoteDetailPage() {
|
export default function NoteDetailPage() {
|
||||||
const id = Number(useParams().noteId);
|
const id = Number(useParams().noteId);
|
||||||
const [search] = useSearchParams();
|
const [search] = useSearchParams();
|
||||||
const selected = search.get('version');
|
const requestedRound = search.get('round') ? Number(search.get('round')) : undefined;
|
||||||
const version = selected ? Number(selected) : undefined;
|
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const [work, setWork] = useState<NoteDetail | null>(null);
|
const [work, setWork] = useState<NoteDetail | null>(null);
|
||||||
const [comment, setComment] = useState('');
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const load = useCallback(async () => {
|
const [selectedImage, setSelectedImage] = useState<{ imageId: number } | null>(null);
|
||||||
const result = await api.getNote(id, version);
|
const [focusImageId, setFocusImageId] = useState<number>();
|
||||||
setWork({ ...result, text_annotations: result.text_annotations ?? [] });
|
const [textDraft, setTextDraft] = useState<(DrawerTextDraft & { target: 'title' | 'description' | 'tags' }) | null>(null);
|
||||||
}, [id, version]);
|
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]);
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
if (!work) return <div className="p-20 text-center text-black/35">正在加载作品…</div>;
|
if (!work) return <main className="grid min-h-[60vh] place-items-center text-sm text-black/35">正在加载作品…</main>;
|
||||||
const latestVersion = Math.max(...work.versions.map((item) => item.version_number));
|
const viewed = work.rounds.find((round) => round.version_number === work.version_number);
|
||||||
const viewingLatest = work.version_number === latestVersion;
|
const current = Boolean(viewed && Number(viewed.review_round_id) === Number(work.active_round_id));
|
||||||
const canReopen = viewingLatest && work.review_status === 'approved' && (user?.role === 'group_admin' || user?.role === 'platform_admin');
|
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 () => {
|
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>
|
||||||
if (!comment.trim()) return;
|
<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>}
|
||||||
setBusy(true);
|
<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>
|
||||||
await api.addComment(id, { content: comment.trim(), author_name: user?.display_name || '工作台', author_role: 'operator' });
|
<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>
|
||||||
setComment(''); await load(); setBusy(false);
|
</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}/>}
|
||||||
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>
|
|
||||||
</main>;
|
</main>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Link, useParams } from 'react-router-dom';
|
||||||
import { ArrowLeft, ArrowRight, CalendarRange, Copy, KeyRound, Pencil, Plus, X } from 'lucide-react';
|
import { ArrowLeft, Copy, ImageIcon, KeyRound, MessageCircle, Pencil, Plus, Search, X } from 'lucide-react';
|
||||||
import type { Project, WorkCollection } from '@shared/types';
|
import type { Note, Project, ReviewStatus } from '@shared/types';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
|
import StatusBadge from '@/components/StatusBadge';
|
||||||
|
|
||||||
|
const projectStatus = { draft: '待提交', reviewing: '验收中', completed: '验收完毕', archived: '已归档' } as const;
|
||||||
|
|
||||||
export default function ProjectPage() {
|
export default function ProjectPage() {
|
||||||
const id = Number(useParams().projectId);
|
const id = Number(useParams().projectId);
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [collections, setCollections] = useState<WorkCollection[]>([]);
|
const [works, setWorks] = useState<Note[]>([]);
|
||||||
const [collectionOpen, setCollectionOpen] = useState(false);
|
const [q, setQ] = useState('');
|
||||||
|
const [status, setStatus] = useState<ReviewStatus | ''>('');
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [accessOpen, setAccessOpen] = useState(false);
|
const [accessOpen, setAccessOpen] = useState(false);
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [desc, setDesc] = useState('');
|
|
||||||
const [projectName, setProjectName] = useState('');
|
const [projectName, setProjectName] = useState('');
|
||||||
const [projectDesc, setProjectDesc] = useState('');
|
const [projectDesc, setProjectDesc] = useState('');
|
||||||
const [accessEnabled, setAccessEnabled] = useState(false);
|
const [accessEnabled, setAccessEnabled] = useState(false);
|
||||||
@@ -21,44 +23,30 @@ export default function ProjectPage() {
|
|||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const [nextProject, nextCollections] = await Promise.all([api.getProject(id), api.listCollections(id)]);
|
const [nextProject, nextWorks] = await Promise.all([api.getProject(id), api.listProjectWorks(id)]);
|
||||||
setProject(nextProject);
|
setProject(nextProject); setWorks(nextWorks);
|
||||||
setCollections(nextCollections);
|
|
||||||
}, [id]);
|
}, [id]);
|
||||||
useEffect(() => { void load(); }, [load]);
|
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 reviewUrl = `${window.location.origin}/review/${project.slug}`;
|
||||||
|
|
||||||
const createCollection = async () => {
|
const openAccess = () => { setAccessEnabled(Boolean(project.customer_access_enabled)); setAccessPassword(''); setExpiresAt(project.access_expires_at?.slice(0, 16) || ''); setMessage(''); setAccessOpen(true); };
|
||||||
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 : '保存失败'); }
|
|
||||||
};
|
|
||||||
|
|
||||||
return <main>
|
return <main>
|
||||||
<section className="border-b border-black/10 bg-[#171714] text-white"><div className="mx-auto max-w-[1500px] px-5 py-12 lg:px-10 lg:py-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="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 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>}
|
<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>
|
||||||
{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>}
|
{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>}
|
||||||
{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>}
|
<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>;
|
</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 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({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 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>; }
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import { api } from '@/api/client';
|
|||||||
|
|
||||||
type Item={file:File;url:string};
|
type Item={file:File;url:string};
|
||||||
export default function UploadPage(){
|
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 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 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 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 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)}};
|
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}/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>
|
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>
|
<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>}
|
{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>
|
{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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""通过 Delivery Desk API 创建作品或上传作品新版本。"""
|
"""通过 Delivery Desk API 创建作品或提交新的验收轮次。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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:
|
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("--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("--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("--external-id", default=None, help="调用方作品唯一标识,用于幂等创建和找回作品")
|
||||||
parser.add_argument("--api-key", default=os.getenv("DELIVERY_DESK_API_KEY"))
|
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("--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)
|
_, projects, _ = request_json(opener, f"{base_url}/api/projects", headers=auth_headers)
|
||||||
project = choose_item(projects, args.project_id, "项目")
|
project = choose_item(projects, args.project_id, "项目")
|
||||||
project_id = int(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')}"
|
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"]
|
image_urls = args.image_urls or ["https://placehold.co/1200x800/png?text=Delivery+Desk+API+Test"]
|
||||||
upload_headers = {**auth_headers, "Content-Type": "application/json"}
|
upload_headers = {**auth_headers, "Content-Type": "application/json"}
|
||||||
@@ -120,28 +109,29 @@ def main() -> int:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if args.work_id is not None:
|
if args.work_id is not None:
|
||||||
_, current, _ = request_json(opener, f"{base_url}/api/notes/{args.work_id}", headers=auth_headers)
|
_, current, _ = request_json(opener, f"{base_url}/api/works/{args.work_id}", headers=auth_headers)
|
||||||
if int(current["project"]["id"]) != project_id or int(current["collection"]["id"]) != collection_id:
|
if int(current["project"]["id"]) != project_id:
|
||||||
raise ApiError(f"作品 {args.work_id} 不属于选定的项目和作品交付集")
|
raise ApiError(f"作品 {args.work_id} 不属于选定的项目")
|
||||||
status, work, _ = request_json(
|
status, work, _ = request_json(
|
||||||
opener,
|
opener,
|
||||||
f"{base_url}/api/notes/{args.work_id}/versions",
|
f"{base_url}/api/works/{args.work_id}/rounds",
|
||||||
method="POST",
|
method="POST",
|
||||||
data=json.dumps(payload).encode("utf-8"),
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
headers=upload_headers,
|
headers=upload_headers,
|
||||||
)
|
)
|
||||||
if status != 201 or not work or int(work.get("id", 0)) != args.work_id:
|
if status != 201 or not work or int(work.get("id", 0)) != args.work_id:
|
||||||
raise ApiError("新版本接口没有返回目标作品")
|
raise ApiError("新验收轮次接口没有返回目标作品")
|
||||||
action = "version_created"
|
action = "round_created"
|
||||||
external_id = work.get("external_id")
|
external_id = work.get("external_id")
|
||||||
else:
|
else:
|
||||||
external_id = args.external_id or f"api-smoke-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
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")
|
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)
|
works_url = f"{base_url}/api/projects/{project_id}/works"
|
||||||
if status not in (200, 201) or not work or int(work.get("collection_id", 0)) != collection_id:
|
status, work, _ = request_json(opener, works_url, method="POST", data=body, headers=upload_headers)
|
||||||
raise ApiError("接口未返回属于目标作品交付集的作品")
|
if status not in (200, 201) or not work or int(work.get("project_id", 0)) != project_id:
|
||||||
_, repeated, _ = request_json(opener, f"{base_url}/api/notes", method="POST", data=body, headers=upload_headers)
|
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"):
|
if int(repeated.get("id", 0)) != int(work["id"]) or not repeated.get("idempotent"):
|
||||||
raise ApiError("相同 externalId 的重复请求未通过幂等校验")
|
raise ApiError("相同 externalId 的重复请求未通过幂等校验")
|
||||||
action = "work_created" if status == 201 else "existing_work_returned"
|
action = "work_created" if status == 201 else "existing_work_returned"
|
||||||
@@ -153,7 +143,6 @@ def main() -> int:
|
|||||||
"action": action,
|
"action": action,
|
||||||
"group": project.get("group_name"),
|
"group": project.get("group_name"),
|
||||||
"project": project.get("name"),
|
"project": project.get("name"),
|
||||||
"collection": collection.get("name"),
|
|
||||||
"work_id": work.get("id"),
|
"work_id": work.get("id"),
|
||||||
"external_id": external_id,
|
"external_id": external_id,
|
||||||
"version_number": work.get("version_number"),
|
"version_number": work.get("version_number"),
|
||||||
|
|||||||
101
tests/api_get_work_annotations.py
Normal file
101
tests/api_get_work_annotations.py
Normal 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
134
tests/test_upload_skill.py
Normal 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()
|
||||||
Reference in New Issue
Block a user