diff --git a/.env.example b/.env.example index f8e74c9..9b3da02 100644 --- a/.env.example +++ b/.env.example @@ -15,20 +15,42 @@ ZHINIAN_PUBLIC_BASE_URL=http://127.0.0.1:3000 # Production requires login by default. Set ZHINIAN_AUTH_REQUIRED=0 only for trusted local development. ZHINIAN_AUTH_REQUIRED=auto ZHINIAN_AUTH_BASE_URL=https:///auth -ZHINIAN_AUTH_CLIENT_ID=customPC -ZHINIAN_AUTH_CLIENT_SECRET= +ZHINIAN_AUTH_CLIENT_ID=app +ZHINIAN_AUTH_CLIENT_SECRET=app ZHINIAN_AUTH_SCOPE=server ZHINIAN_AUTH_ISSUER=https://pig4cloud.com -ZHINIAN_AUTH_PASSWORD_ENC_KEY= +ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud ZHINIAN_AUTH_SESSION_SECRET=change-me-to-a-long-random-secret +# Comma-separated authorities that can access logs/settings/accounts. +ZHINIAN_ADMIN_AUTHORITIES=ROLE_ADMIN,sys_user_view,sys_log_view,sys_config_view +# Comma-separated usernames that can access logs/settings/accounts. Defaults include ceshiop. +ZHINIAN_ADMIN_USERS=ceshiop # Optional overrides when endpoints do not follow AUTH_BASE defaults. ZHINIAN_AUTH_AUTHORIZE_URL= ZHINIAN_AUTH_TOKEN_URL= ZHINIAN_AUTH_JWKS_URL= ZHINIAN_AUTH_LOGOUT_URL= +# Organization/member management API from basic-capability-services-biz. +# Base URL should include the gateway/service prefix before /organization... +ZHINIAN_ORG_API_BASE_URL= +# Optional fallback token. Account management forwards the current logged-in access_token by default. +ZHINIAN_ORG_API_TOKEN= +ZHINIAN_STAFF_API_BASE_URL= +# Optional fallback token. If empty, uses the current logged-in access_token or ZHINIAN_ORG_API_TOKEN. +ZHINIAN_STAFF_API_TOKEN= +ZHINIAN_ORG_TENANT_ID= +ZHINIAN_ORG_ID= +# Optional path overrides when the gateway exposes organization APIs through wrapper routes. +ZHINIAN_ORG_LIST_PATH= +ZHINIAN_ORG_GROUP_LIST_PATH= +ZHINIAN_ORG_ROLE_LIST_PATH= +# Defaults to /adminOrganization/organizationMember/organizationMemberList through hotelStaff. +ZHINIAN_ORG_MEMBER_LIST_PATH= + # Public API v1 and worker task management. -# Format: clientId:key,anotherClient:anotherKey +# Format: accountId:key,anotherAccount:anotherKey. +# Jobs and assets are partitioned by accountId as ownerId `api:`. ZHINIAN_API_KEYS=demo-agent:change-me-public-api-key ZHINIAN_INTERNAL_WORKER_TOKEN=change-me-worker-token ZHINIAN_WEBHOOK_SECRET= diff --git a/README.md b/README.md index 27b89ca..116ccc9 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,11 @@ Docker 部署默认使用 `docker-compose.yml` 同时启动 Web 服务和 `zhini - `/create` 创作,合并图片、视频、局部重绘、智能超清 - `/assets` 结果,保留历史资产和历史生成任务 - `/image-edit` 兼容旧入口,自动跳转到创作页的局部重绘 -- `/settings` 设置 +- `/logs` 日志,管理员可见 +- `/settings` 设置,管理员可见 +- `/accounts` 账号管理,管理员可见 -主导航只保留创作、结果、设置,不包含工作台、项目、模板中心、Billing 或桌面端入口。 +普通用户主导航只显示创作和结果;管理员会额外看到日志、设置和账号管理。不包含工作台、项目、模板中心、Billing 或桌面端入口。 ## 账户登录 / SSO @@ -72,16 +74,32 @@ https://你的域名/api/auth/callback - `ZHINIAN_AUTH_REQUIRED=auto`:生产默认启用;本地可信开发可设为 `0` - `ZHINIAN_AUTH_BASE_URL=https:///auth` -- `ZHINIAN_AUTH_CLIENT_ID=customPC` -- `ZHINIAN_AUTH_CLIENT_SECRET` +- `ZHINIAN_AUTH_CLIENT_ID=app` +- `ZHINIAN_AUTH_CLIENT_SECRET=app` - `ZHINIAN_AUTH_SCOPE=server` - `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com` -- `ZHINIAN_AUTH_PASSWORD_ENC_KEY`:按 AgentBus 方式对 password grant 的密码做 AES-CFB 加密;留空则透传明文 +- `ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud`:按认证中心 `security.encode-key` 对 password grant 的密码做 AES-CFB 加密 - `ZHINIAN_AUTH_SESSION_SECRET`:长随机字符串,用于签名本地登录态 +- `ZHINIAN_ADMIN_AUTHORITIES`:逗号分隔的管理员权限码;命中后可访问 `/logs`、`/settings`、`/accounts` +- `ZHINIAN_ADMIN_USERS=ceshiop`:逗号分隔的管理员账号;默认 `ceshiop` 是管理员 -`/create`、`/assets`、`/settings`、第一方生成/资产 API、以及本地上传和生成结果文件都会受登录态保护。`/api/v1/*` 继续使用 `ZHINIAN_API_KEYS`,不走浏览器 SSO。 +`/create`、`/assets`、`/settings`、`/logs`、`/accounts`、第一方生成/资产 API、以及本地上传和生成结果文件都会受登录态保护。`/logs`、`/settings`、`/accounts` 和 `/api/admin/*` 需要管理员权限。`/api/v1/*` 继续使用 `ZHINIAN_API_KEYS`,不走浏览器 SSO。 -如果认证中心客户端未加入 `security.ignore-clients`,登录页的账号密码方式会要求图形验证码;验证码图片由 `/api/auth/captcha` 代理 `${AUTH_BASE}/code/image` 获取。 +如果认证中心客户端未加入 `security.ignore-clients`,`/oauth2/token` 可能返回“验证码不能为空”。当前推荐使用运维分配的 `app/app` 客户端;如改用 `customPC` 登录平台用户,需要认证中心把该 clientId 加入 `security.ignore-clients`。 + +## 组织账号管理 + +后台账号管理通过组织模块接口维护成员、角色、部门绑定和成员状态,并通过企业端用户接口创建用户、重置密码。按运维提供的组织能力接口文档,需要在服务端配置: + +- `ZHINIAN_ORG_API_BASE_URL`:组织能力网关或统一服务前缀,例如 `https:///hotelStaff` +- `ZHINIAN_ORG_API_TOKEN`:备用 Bearer Token;默认优先转发当前登录管理员的 `access_token` +- `ZHINIAN_STAFF_API_BASE_URL`:企业端用户服务网关或统一服务前缀,例如 `https:///hotelStaff` +- `ZHINIAN_STAFF_API_TOKEN`:备用 Bearer Token,可为空,默认优先转发当前登录管理员的 `access_token` +- `ZHINIAN_ORG_TENANT_ID`:需要多租户 Header 时填写 +- `ZHINIAN_ORG_ID`:默认组织 ID,可为空,系统会优先使用组织列表第一项 +- `ZHINIAN_ORG_MEMBER_LIST_PATH`:可选,成员查询路径覆盖;默认 `/adminOrganization/organizationMember/organizationMemberList` + +成员分页列表通过 `hotelStaff` 的 `/adminOrganization/organizationMember/organizationMemberList` 对外代理;只有直连基础组织服务内部 `/organizationMember/organizationMemberList` 时才需要 `from: Y`。创建账号会优先调用 `/adminOrganization/organizationMember/addOrganizationMemberAndCreatePlatformUser`,密码重置调用 `/adminPcUser/resetPlatformUserPassword`;这些接口默认使用当前登录账号的 token,要求该账号具备管理员角色 `1`。 ## 图片创作引擎 @@ -143,11 +161,19 @@ cp .env.example .env.local - `ZHINIAN_AUTH_REQUIRED=auto` - `ZHINIAN_AUTH_BASE_URL` -- `ZHINIAN_AUTH_CLIENT_ID=customPC` +- `ZHINIAN_AUTH_CLIENT_ID=app` - `ZHINIAN_AUTH_CLIENT_SECRET` - `ZHINIAN_AUTH_SCOPE=server` - `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com` - `ZHINIAN_AUTH_SESSION_SECRET` +- `ZHINIAN_ADMIN_AUTHORITIES` +- `ZHINIAN_ADMIN_USERS` +- `ZHINIAN_ORG_API_BASE_URL` +- `ZHINIAN_ORG_API_TOKEN` +- `ZHINIAN_STAFF_API_BASE_URL` +- `ZHINIAN_STAFF_API_TOKEN` +- `ZHINIAN_ORG_TENANT_ID` +- `ZHINIAN_ORG_ID` - `IMAGE_GENERATE_ENGINE=jimeng` 或 `evolink` - `IMAGE_INPAINT_ENGINE=jimeng` 或 `evolink` - `VOLCENGINE_ACCESS_KEY_ID` @@ -200,6 +226,8 @@ ZHINIAN_API_KEYS=demo-agent:change-me-public-api-key ZHINIAN_INTERNAL_WORKER_TOKEN=change-me-worker-token ``` +`ZHINIAN_API_KEYS` 冒号前的值是账号 ID,开放 API 任务和资产会按该账号 ID 写入独立 owner 分区,例如 `api:demo-agent`。 + 主要接口: - `GET /api/v1/capabilities` diff --git a/README.zh-CN.md b/README.zh-CN.md index 4babbd8..c96ea3f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,8 +12,9 @@ - 统一创作入口:`/create` - 结果资产管理:`/assets` -- 服务与引擎配置:`/settings` -- 后台日志管理:`/logs` +- 服务与引擎配置:`/settings`,管理员可见 +- 后台日志管理:`/logs`,管理员可见 +- 账号管理:`/accounts`,管理员可见 - 图片生成:即梦图片生成 4.6 或 EvoLink GPT Image 2 - 图片编辑:局部重绘、智能超清 - 视频生成:Seedance 2.0 @@ -141,6 +142,7 @@ npm run info | `/assets` | 历史任务与资产 | | `/logs` | 后台日志管理 | | `/settings` | 接口、引擎和服务配置 | +| `/accounts` | 组织成员账号管理 | ## 账户登录 / SSO @@ -158,16 +160,34 @@ https://你的域名/api/auth/callback |------|------| | `ZHINIAN_AUTH_REQUIRED` | `auto` 默认策略;生产启用,本地可信开发可设 `0` | | `ZHINIAN_AUTH_BASE_URL` | 认证服务网关地址,例如 `https:///auth` | -| `ZHINIAN_AUTH_CLIENT_ID` | OAuth2 客户端 ID,默认 `customPC` | +| `ZHINIAN_AUTH_CLIENT_ID` | OAuth2 客户端 ID,默认 `app` | | `ZHINIAN_AUTH_CLIENT_SECRET` | OAuth2 客户端密钥,只能保存在服务端 | | `ZHINIAN_AUTH_SCOPE` | 默认 `server` | | `ZHINIAN_AUTH_ISSUER` | JWT issuer,默认 `https://pig4cloud.com` | -| `ZHINIAN_AUTH_PASSWORD_ENC_KEY` | 按 AgentBus 方式对 password grant 的密码做 AES-CFB 加密;留空则透传明文 | +| `ZHINIAN_AUTH_PASSWORD_ENC_KEY` | 按认证中心 `security.encode-key` 对 password grant 的密码做 AES-CFB 加密,默认示例 `thanks,pig4cloud` | | `ZHINIAN_AUTH_SESSION_SECRET` | 本地会话签名密钥,使用长随机字符串 | +| `ZHINIAN_ADMIN_AUTHORITIES` | 管理员权限码白名单,逗号分隔 | +| `ZHINIAN_ADMIN_USERS` | 管理员账号白名单,逗号分隔,默认 `ceshiop` | -受保护范围包括 `/create`、`/assets`、`/settings`、第一方生成/资产 API,以及本地 `/uploads/*` 和 `/generated-results/*` 文件。开放 `/api/v1/*` 仍使用 API Key,Worker 仍使用内部 token,不走浏览器 SSO。 +受保护范围包括 `/create`、`/assets`、`/settings`、`/logs`、`/accounts`、第一方生成/资产 API,以及本地 `/uploads/*` 和 `/generated-results/*` 文件。普通用户主导航只显示创作和结果;`/logs`、`/settings`、`/accounts` 和 `/api/admin/*` 需要管理员权限。开放 `/api/v1/*` 仍使用 API Key,Worker 仍使用内部 token,不走浏览器 SSO。 -如果认证中心客户端没有加入 `security.ignore-clients`,账号密码登录会要求图形验证码;验证码图片由 `/api/auth/captcha` 代理 `${AUTH_BASE}/code/image` 获取。 +如果认证中心客户端没有加入 `security.ignore-clients`,`/oauth2/token` 可能返回“验证码不能为空”。当前推荐使用运维分配的 `app/app` 客户端;如改用 `customPC` 登录平台用户,需要认证中心把该 clientId 加入 `security.ignore-clients`。 + +## 组织账号管理 + +后台账号管理通过组织模块接口维护成员、角色、部门绑定和成员状态,并通过企业端用户接口创建用户、重置密码。按运维提供的组织能力接口文档,需要在服务端配置: + +| 变量 | 说明 | +|------|------| +| `ZHINIAN_ORG_API_BASE_URL` | 组织能力网关或统一服务前缀,例如 `https:///hotelStaff` | +| `ZHINIAN_ORG_API_TOKEN` | 备用 Bearer Token;默认优先转发当前登录管理员的 `access_token` | +| `ZHINIAN_STAFF_API_BASE_URL` | 企业端用户服务网关或统一服务前缀,例如 `https:///hotelStaff` | +| `ZHINIAN_STAFF_API_TOKEN` | 备用 Bearer Token,可为空,默认优先转发当前登录管理员的 `access_token` | +| `ZHINIAN_ORG_TENANT_ID` | 需要多租户 Header 时填写 | +| `ZHINIAN_ORG_ID` | 默认组织 ID,可为空,系统会优先使用组织列表第一项 | +| `ZHINIAN_ORG_MEMBER_LIST_PATH` | 可选,成员查询路径覆盖;默认 `/adminOrganization/organizationMember/organizationMemberList` | + +成员分页列表通过 `hotelStaff` 的 `/adminOrganization/organizationMember/organizationMemberList` 对外代理;只有直连基础组织服务内部 `/organizationMember/organizationMemberList` 时才需要 `from: Y`。创建账号会优先调用 `/adminOrganization/organizationMember/addOrganizationMemberAndCreatePlatformUser`,密码重置调用 `/adminPcUser/resetPlatformUserPassword`;这些接口默认使用当前登录账号的 token,要求该账号具备管理员角色 `1`。 ## 引擎说明 @@ -206,6 +226,8 @@ ZHINIAN_API_KEYS=demo-agent:change-me-public-api-key ZHINIAN_INTERNAL_WORKER_TOKEN=change-me-worker-token ``` +`ZHINIAN_API_KEYS` 冒号前的值是账号 ID,开放 API 任务和资产会按该账号 ID 写入独立 owner 分区,例如 `api:demo-agent`。 + 调用示例: ```bash @@ -252,7 +274,15 @@ cp .env.example .env.local | `ZHINIAN_AUTH_CLIENT_ID` | OAuth2 客户端 ID | | `ZHINIAN_AUTH_CLIENT_SECRET` | OAuth2 客户端密钥 | | `ZHINIAN_AUTH_SESSION_SECRET` | 本地登录态签名密钥 | -| `ZHINIAN_API_KEYS` | 开放 API Key,格式 `clientId:key,clientId2:key2` | +| `ZHINIAN_ADMIN_AUTHORITIES` | 管理员权限码白名单 | +| `ZHINIAN_ADMIN_USERS` | 管理员账号白名单,默认 `ceshiop` | +| `ZHINIAN_ORG_API_BASE_URL` | 组织账号接口 Base URL | +| `ZHINIAN_ORG_API_TOKEN` | 组织账号接口备用 Bearer Token | +| `ZHINIAN_STAFF_API_BASE_URL` | 企业端用户接口 Base URL | +| `ZHINIAN_STAFF_API_TOKEN` | 企业端用户接口备用 Bearer Token,可为空 | +| `ZHINIAN_ORG_TENANT_ID` | 组织接口租户 ID | +| `ZHINIAN_ORG_ID` | 默认组织 ID | +| `ZHINIAN_API_KEYS` | 开放 API Key,格式 `账号ID:key,账号ID2:key2`;任务和资产按账号 ID 写入独立 owner 分区 | | `ZHINIAN_INTERNAL_WORKER_TOKEN` | 内部 Worker tick 接口令牌 | | `ZHINIAN_WEBHOOK_SECRET` | Webhook 签名密钥,可选 | | `ZHINIAN_WORKER_*` | Worker 间隔、批量、锁超时、重试配置 | diff --git a/app/accounts/page.tsx b/app/accounts/page.tsx new file mode 100644 index 0000000..80ab80e --- /dev/null +++ b/app/accounts/page.tsx @@ -0,0 +1,9 @@ +import { AccountManager } from "@/components/account-manager"; +import { requireAdminUser } from "@/lib/server/auth/current-user"; + +export const dynamic = "force-dynamic"; + +export default async function AccountsPage() { + await requireAdminUser(); + return ; +} diff --git a/app/api/admin/accounts/groups/route.ts b/app/api/admin/accounts/groups/route.ts new file mode 100644 index 0000000..c4f66e9 --- /dev/null +++ b/app/api/admin/accounts/groups/route.ts @@ -0,0 +1,51 @@ +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireAdminSession } from "@/lib/server/auth/current-user"; +import { + createOrganizationGroup, + getOrganizationApiConfig, + listOrganizationGroups +} from "@/lib/server/organization-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + try { + const session = await requireAdminSession(); + const context = { accessToken: session.accessToken }; + const config = getOrganizationApiConfig(context.accessToken); + if (!config.configured) { + throw Object.assign(new Error(`组织接口配置不完整:${config.missing.join(", ")}`), { status: 503 }); + } + const body = await readJsonBody>(request); + const organizationId = requiredString(body, "organizationId", "组织"); + const groupName = requiredString(body, "groupName", "部门名称"); + await createOrganizationGroup({ + organizationId, + groupName, + groupDesc: optionalString(body.groupDesc), + parentId: optionalString(body.parentId) + }, context); + const groups = await listOrganizationGroups(organizationId, context); + const group = groups.find((item) => item.groupName === groupName) || null; + return jsonOk({ ok: true, group, groups }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.admin.accounts.groups", logClientErrors: true }); + } +} + +function requiredString(body: Record, key: string, label: string): string { + const value = optionalString(body[key]); + if (!value) throw badRequest(`${label}不能为空。`); + return value; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function badRequest(message: string): Error & { status: number } { + const error = new Error(message) as Error & { status: number }; + error.status = 400; + return error; +} diff --git a/app/api/admin/accounts/password/route.ts b/app/api/admin/accounts/password/route.ts new file mode 100644 index 0000000..2f9a744 --- /dev/null +++ b/app/api/admin/accounts/password/route.ts @@ -0,0 +1,60 @@ +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireAdminSession } from "@/lib/server/auth/current-user"; +import { getOrganizationApiConfig, resetPlatformUserPassword } from "@/lib/server/organization-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + try { + const session = await requireAdminSession(); + const context = { accessToken: session.accessToken }; + const config = getOrganizationApiConfig(context.accessToken); + if (!config.staffConfigured) { + throw Object.assign(new Error(`企业端用户接口配置不完整:${config.staffMissing.join(", ")}`), { status: 503 }); + } + const body = await readJsonBody>(request); + await resetPlatformUserPassword({ + tenantId: tenantIdNumber(config.tenantId), + userId: requiredNumber(body.userId, "用户 ID"), + newPassword: requiredString(body, "newPassword", "新密码"), + mustChangePassword: booleanValue(body.mustChangePassword, true) + }, context); + return jsonOk({ ok: true }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.admin.accounts.password", logClientErrors: true }); + } +} + +function requiredString(body: Record, key: string, label: string): string { + const value = body[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + throw badRequest(`${label}不能为空。`); +} + +function requiredNumber(value: unknown, label: string): number { + const parsed = typeof value === "number" ? value : Number(value); + if (Number.isFinite(parsed)) return parsed; + throw badRequest(`${label}必须是数字。`); +} + +function tenantIdNumber(value: string): number { + const tenantId = Number(value); + if (!Number.isFinite(tenantId)) throw badRequest("租户 ID 必须是数字。"); + return tenantId; +} + +function booleanValue(value: unknown, fallback: boolean): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + if (value === "true") return true; + if (value === "false") return false; + } + return fallback; +} + +function badRequest(message: string): Error & { status: number } { + const error = new Error(message) as Error & { status: number }; + error.status = 400; + return error; +} diff --git a/app/api/admin/accounts/route.ts b/app/api/admin/accounts/route.ts new file mode 100644 index 0000000..aca27c8 --- /dev/null +++ b/app/api/admin/accounts/route.ts @@ -0,0 +1,266 @@ +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireAdminSession } from "@/lib/server/auth/current-user"; +import { + addOrganizationMemberAndCreatePlatformUser, + emptyPage, + getOrganizationApiConfig, + isOrganizationPermissionDenied, + isOrganizationRouteNotFound, + listOrganizationGroups, + listOrganizationMembers, + listOrganizationRoles, + listOrganizations, + modifyOrganizationMemberStatus, + removeOrganizationMember, + updateOrganizationMember +} from "@/lib/server/organization-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + try { + const session = await requireAdminSession(); + const context = { accessToken: session.accessToken }; + const config = getOrganizationApiConfig(context.accessToken); + const url = new URL(request.url); + const pageNum = positiveInteger(url.searchParams.get("pageNum"), 1); + const pageSize = positiveInteger(url.searchParams.get("pageSize"), 10, 50); + + if (!config.configured) { + return jsonOk({ + configured: false, + missing: config.missing, + staffConfigured: config.staffConfigured, + staffMissing: config.staffMissing, + selectedOrganizationId: config.defaultOrganizationId, + organizations: [], + groups: [], + roles: [], + members: emptyPage(pageNum, pageSize), + warnings: [], + memberListAvailable: false, + passwordManagementAvailable: config.staffConfigured + }); + } + + const warnings: string[] = []; + let organizations: Awaited> = []; + try { + organizations = await listOrganizations(context); + } catch (error) { + if (!isOrganizationRouteNotFound(error) || !config.defaultOrganizationId) throw error; + warnings.push(error.message); + organizations = [{ + organizationId: config.defaultOrganizationId, + organizationName: config.defaultOrganizationId + }]; + } + const selectedOrganizationId = url.searchParams.get("organizationId")?.trim() || + config.defaultOrganizationId || + organizations[0]?.organizationId || + ""; + + if (!selectedOrganizationId) { + return jsonOk({ + configured: true, + missing: [], + staffConfigured: config.staffConfigured, + staffMissing: config.staffMissing, + selectedOrganizationId: "", + organizations, + groups: [], + roles: [], + members: emptyPage(pageNum, pageSize), + warnings: uniqueWarnings(warnings), + memberListAvailable: false, + passwordManagementAvailable: config.staffConfigured + }); + } + + const [groupsResult, rolesResult, membersResult] = await Promise.allSettled([ + listOrganizationGroups(selectedOrganizationId, context), + listOrganizationRoles(selectedOrganizationId, context), + listOrganizationMembers({ + organizationId: selectedOrganizationId, + pageNum, + pageSize, + memberName: url.searchParams.get("memberName") || undefined, + memberStatus: statusFilter(url.searchParams.get("memberStatus")) + }, context) + ]); + const groups = routeFallback(groupsResult, warnings, []); + const roles = routeFallback(rolesResult, warnings, []); + const members = memberListFallback(membersResult, warnings, emptyPage(pageNum, pageSize)); + const memberListAvailable = membersResult.status === "fulfilled"; + + return jsonOk({ + configured: true, + missing: [], + staffConfigured: config.staffConfigured, + staffMissing: config.staffMissing, + selectedOrganizationId, + organizations, + groups, + roles, + members, + warnings: uniqueWarnings(warnings), + memberListAvailable, + passwordManagementAvailable: config.staffConfigured + }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.admin.accounts", logClientErrors: true }); + } +} + +export async function POST(request: Request) { + try { + const session = await requireAdminSession(); + const context = { accessToken: session.accessToken }; + const config = getOrganizationApiConfig(context.accessToken); + if (!config.staffConfigured) { + throw Object.assign(new Error(`企业端用户接口配置不完整:${config.staffMissing.join(", ")}`), { status: 503 }); + } + const body = await readJsonBody>(request); + const memberName = requiredString(body, "memberName", "成员名称"); + const memberPhone = requiredString(body, "memberPhone", "手机号"); + const user = await addOrganizationMemberAndCreatePlatformUser({ + tenantId: tenantIdNumber(config.tenantId), + username: optionalString(body.username), + phone: optionalString(body.phone) || memberPhone, + name: optionalString(body.name) || memberName, + nickname: optionalString(body.nickname) || optionalString(body.name) || memberName, + initialPassword: optionalString(body.initialPassword), + mustChangePassword: booleanValue(body.mustChangePassword, true), + memberName, + memberPhone, + roleId: requiredString(body, "roleId", "角色"), + organizationId: requiredString(body, "organizationId", "组织"), + groupId: requiredString(body, "groupId", "部门") + }, context); + return jsonOk({ ok: true, user }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.admin.accounts", logClientErrors: true }); + } +} + +export async function PATCH(request: Request) { + try { + const session = await requireAdminSession(); + const context = { accessToken: session.accessToken }; + const body = await readJsonBody>(request); + const update = { + memberId: requiredString(body, "memberId", "成员 ID"), + memberName: optionalString(body.memberName), + roleId: optionalString(body.roleId), + groupId: optionalString(body.groupId) + }; + if (!update.memberName && !update.roleId && !update.groupId) { + throw badRequest("至少需要修改一个成员字段。"); + } + await updateOrganizationMember(update, context); + return jsonOk({ ok: true }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.admin.accounts", logClientErrors: true }); + } +} + +export async function PUT(request: Request) { + try { + const session = await requireAdminSession(); + const context = { accessToken: session.accessToken }; + const body = await readJsonBody>(request); + await modifyOrganizationMemberStatus({ + memberId: requiredString(body, "memberId", "成员 ID"), + memberStatus: memberStatus(requiredString(body, "memberStatus", "成员状态")) + }, context); + return jsonOk({ ok: true }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.admin.accounts", logClientErrors: true }); + } +} + +export async function DELETE(request: Request) { + try { + const session = await requireAdminSession(); + const context = { accessToken: session.accessToken }; + const body = await readJsonBody>(request); + await removeOrganizationMember(requiredString(body, "memberId", "成员 ID"), context); + return jsonOk({ ok: true }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.admin.accounts", logClientErrors: true }); + } +} + +function requiredString(body: Record, key: string, label: string): string { + const value = optionalString(body[key]); + if (!value) throw badRequest(`${label}不能为空。`); + return value; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function memberStatus(value: string): "0" | "1" | "2" { + if (value === "0" || value === "1" || value === "2") return value; + throw badRequest("成员状态只能是 0、1、2。"); +} + +function statusFilter(value: string | null): string | undefined { + return value === "0" || value === "1" || value === "2" ? value : undefined; +} + +function routeFallback(result: PromiseSettledResult, warnings: string[], fallback: T): T { + if (result.status === "fulfilled") return result.value; + if (!isOrganizationRouteNotFound(result.reason)) throw result.reason; + warnings.push(result.reason.message); + return fallback; +} + +function memberListFallback(result: PromiseSettledResult, warnings: string[], fallback: T): T { + if (result.status === "fulfilled") return result.value; + if (!isOrganizationRouteNotFound(result.reason) && !isOrganizationPermissionDenied(result.reason)) { + throw result.reason; + } + warnings.push(memberListWarning(result.reason)); + return fallback; +} + +function memberListWarning(error: unknown): string { + if (isOrganizationPermissionDenied(error)) { + return "当前登录 token 缺少上游 hotelStaff 管理员角色,成员列表暂不可用。"; + } + return error instanceof Error ? error.message : String(error); +} + +function uniqueWarnings(warnings: string[]): string[] { + return [...new Set(warnings.filter(Boolean))]; +} + +function tenantIdNumber(value: string): number { + const tenantId = Number(value); + if (!Number.isFinite(tenantId)) throw badRequest("租户 ID 必须是数字。"); + return tenantId; +} + +function booleanValue(value: unknown, fallback: boolean): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + if (value === "true") return true; + if (value === "false") return false; + } + return fallback; +} + +function positiveInteger(value: string | null, fallback: number, max = 200): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) return fallback; + return Math.min(parsed, max); +} + +function badRequest(message: string): Error & { status: number } { + const error = new Error(message) as Error & { status: number }; + error.status = 400; + return error; +} diff --git a/app/api/auth/password/route.ts b/app/api/auth/password/route.ts index 66f185c..383833d 100644 --- a/app/api/auth/password/route.ts +++ b/app/api/auth/password/route.ts @@ -1,8 +1,9 @@ -import { SESSION_COOKIE_NAME, getAuthRuntimeConfig, safeNextPath, shouldUseSecureAuthCookie } from "@/lib/auth/config"; +import { getAuthRuntimeConfig, safeNextPath } from "@/lib/auth/config"; import { createSessionCookieValue } from "@/lib/auth/session"; import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; import { createSessionFromClaims, verifyAuthJwt } from "@/lib/server/auth/jwt"; import { prepareAuthPassword } from "@/lib/server/auth/password"; +import { setSessionCookieValue } from "@/lib/server/auth/session-cookie"; export const runtime = "nodejs"; @@ -54,19 +55,21 @@ export async function POST(request: Request) { }); if (!token.access_token) throw new PasswordLoginError("认证中心没有返回 access_token。", 502); const claims = await verifyAuthJwt(token.access_token, config); - const session = createSessionFromClaims(claims, config, parseExpiresIn(token.expires_in)); + const session = createSessionFromClaims(claims, config, parseExpiresIn(token.expires_in), { + accessToken: token.access_token, + tokenType: token.token_type + }); const response = jsonOk({ ok: true, redirectTo: safeNextPath(body.next), user: session.user }); - response.cookies.set(SESSION_COOKIE_NAME, await createSessionCookieValue(session, config.sessionSecret), { - httpOnly: true, - sameSite: "lax", - secure: shouldUseSecureAuthCookie(request.url), - path: "/", - expires: new Date(session.expiresAt * 1000) - }); + setSessionCookieValue( + response, + request.url, + await createSessionCookieValue(session, config.sessionSecret), + new Date(session.expiresAt * 1000) + ); return response; } catch (error) { return jsonError(error, 401); diff --git a/app/api/generations/image/route.ts b/app/api/generations/image/route.ts index 16f27b0..3882436 100644 --- a/app/api/generations/image/route.ts +++ b/app/api/generations/image/route.ts @@ -5,6 +5,7 @@ import { requestOrigin } from "@/lib/server/runtime"; import { submitImageJob } from "@/lib/server/generation-service"; import { assemblePrompt, type PromptAssemblyInput, type PromptMaterial } from "@/lib/prompt/assembler"; import type { EnabledImageCapability } from "@/lib/types"; +import type { ImageCreationEngine } from "@/lib/evolink/image-client"; export const runtime = "nodejs"; @@ -23,6 +24,7 @@ export async function POST(request: Request) { const user = await requireAppUser(); const body = await readJsonBody<{ capability?: EnabledImageCapability; + engine?: ImageCreationEngine; prompt?: string; imageUrls?: string[]; materials?: PromptMaterial[]; @@ -46,6 +48,7 @@ export async function POST(request: Request) { const job = await submitImageJob({ ownerId: user.id, capability, + engine: body.engine, prompt: body.prompt || assembled?.prompt, imageUrls: body.imageUrls || materialImages, inputAssetIds: body.inputAssetIds || (body.materials || []).map((material) => material.id).filter(Boolean) as string[], diff --git a/app/api/image-templates/[id]/route.ts b/app/api/image-templates/[id]/route.ts new file mode 100644 index 0000000..5b684c7 --- /dev/null +++ b/app/api/image-templates/[id]/route.ts @@ -0,0 +1,35 @@ +import { deleteImageTemplate, updateImageTemplate } from "@/lib/server/data-store"; +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireAppUser } from "@/lib/server/auth/current-user"; +import { normalizeImageTemplateUpdate } from "@/lib/server/image-template-input"; + +export const runtime = "nodejs"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +export async function PATCH(request: Request, context: RouteContext) { + try { + const user = await requireAppUser(); + const { id } = await context.params; + const body = await readJsonBody(request); + const template = await updateImageTemplate(id, user.id, normalizeImageTemplateUpdate(body)); + if (!template) return jsonError(new Error("模板不存在"), 404); + return jsonOk({ template }); + } catch (error) { + return jsonError(error); + } +} + +export async function DELETE(_request: Request, context: RouteContext) { + try { + const user = await requireAppUser(); + const { id } = await context.params; + const template = await deleteImageTemplate(id, user.id); + if (!template) return jsonError(new Error("模板不存在"), 404); + return jsonOk({ template }); + } catch (error) { + return jsonError(error); + } +} diff --git a/app/api/image-templates/route.ts b/app/api/image-templates/route.ts new file mode 100644 index 0000000..be6d26b --- /dev/null +++ b/app/api/image-templates/route.ts @@ -0,0 +1,29 @@ +import { createImageTemplate, listImageTemplates } from "@/lib/server/data-store"; +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireAppUser } from "@/lib/server/auth/current-user"; +import { normalizeImageTemplateCreate } from "@/lib/server/image-template-input"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + const user = await requireAppUser(); + return jsonOk({ templates: await listImageTemplates(user.id) }); + } catch (error) { + return jsonError(error, 500); + } +} + +export async function POST(request: Request) { + try { + const user = await requireAppUser(); + const body = await readJsonBody(request); + const template = await createImageTemplate({ + ownerId: user.id, + ...normalizeImageTemplateCreate(body) + }); + return jsonOk({ template }, { status: 201 }); + } catch (error) { + return jsonError(error); + } +} diff --git a/app/api/logs/route.ts b/app/api/logs/route.ts index e4484e2..c38e4c1 100644 --- a/app/api/logs/route.ts +++ b/app/api/logs/route.ts @@ -1,4 +1,5 @@ import { jsonError, jsonOk } from "@/lib/server/api"; +import { requireAdminUser } from "@/lib/server/auth/current-user"; import { appLogFilePath, appLogMaxBytes, clearAppLogs, listAppLogs, type AppLogLevel } from "@/lib/server/log-manager"; export const runtime = "nodejs"; @@ -6,6 +7,7 @@ export const dynamic = "force-dynamic"; export async function GET(request: Request) { try { + await requireAdminUser(); const url = new URL(request.url); const level = parseLevel(url.searchParams.get("level")); const q = url.searchParams.get("q") || undefined; @@ -27,6 +29,7 @@ export async function GET(request: Request) { export async function DELETE(request: Request) { try { + await requireAdminUser(); await clearAppLogs(); return jsonOk({ ok: true }); } catch (error) { diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index fc80b21..e7e5523 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,12 +1,12 @@ import { getApiSettings, saveApiSettings } from "@/lib/server/app-settings"; import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; -import { requireAppUser } from "@/lib/server/auth/current-user"; +import { requireAdminUser } from "@/lib/server/auth/current-user"; export const runtime = "nodejs"; export async function GET() { try { - await requireAppUser(); + await requireAdminUser(); return jsonOk(await getApiSettings()); } catch (error) { return jsonError(error, 500); @@ -15,7 +15,7 @@ export async function GET() { export async function POST(request: Request) { try { - await requireAppUser(); + await requireAdminUser(); const body = await readJsonBody<{ values?: Record }>(request); return jsonOk(await saveApiSettings(body.values || {})); } catch (error) { diff --git a/app/api/v1/assets/route.ts b/app/api/v1/assets/route.ts index 9e16212..aab4756 100644 --- a/app/api/v1/assets/route.ts +++ b/app/api/v1/assets/route.ts @@ -1,9 +1,9 @@ import { createAsset } from "@/lib/server/data-store"; import { jsonOk, readJsonBody } from "@/lib/server/api"; -import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth"; +import { authenticatePublicApiRequest, publicApiOwnerId } from "@/lib/server/public-api-auth"; import { listPublicApiAssets } from "@/lib/server/public-api-assets"; import { publicApiError } from "@/lib/server/public-api-response"; -import { DEFAULT_OWNER_ID, requestOrigin } from "@/lib/server/runtime"; +import { requestOrigin } from "@/lib/server/runtime"; import { saveUploadAsset } from "@/lib/server/storage"; import type { AssetKind } from "@/lib/types"; @@ -21,13 +21,14 @@ export async function GET(request: Request) { export async function POST(request: Request) { try { const client = authenticatePublicApiRequest(request); + const ownerId = publicApiOwnerId(client); const contentType = request.headers.get("content-type") || ""; if (contentType.includes("multipart/form-data")) { const form = await request.formData(); const files = form.getAll("files").filter((item): item is File => item instanceof File); if (!files.length) throw new Error("No files uploaded."); const assets = await Promise.all(files.map(async (file) => saveUploadAsset({ - ownerId: DEFAULT_OWNER_ID, + ownerId, bytes: await awaitFileBytes(file), fileName: file.name, contentType: file.type || "application/octet-stream", @@ -45,7 +46,7 @@ export async function POST(request: Request) { }>(request); if (!body.url) throw new Error("url is required"); const asset = await createAsset({ - ownerId: DEFAULT_OWNER_ID, + ownerId, kind: body.kind || "image", name: body.name || "外部素材", url: body.url, diff --git a/app/api/v1/jobs/[id]/cancel/route.ts b/app/api/v1/jobs/[id]/cancel/route.ts index 1b5144a..be2bc38 100644 --- a/app/api/v1/jobs/[id]/cancel/route.ts +++ b/app/api/v1/jobs/[id]/cancel/route.ts @@ -1,6 +1,6 @@ import { clearGenerationJobLock, getGenerationJob, updateGenerationJob } from "@/lib/server/data-store"; import { jsonError, jsonOk } from "@/lib/server/api"; -import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth"; +import { authenticatePublicApiRequest, publicApiOwnerId } from "@/lib/server/public-api-auth"; import { publicApiError } from "@/lib/server/public-api-response"; export const runtime = "nodejs"; @@ -10,7 +10,9 @@ export async function POST(request: Request, context: { params: Promise<{ id: st const client = authenticatePublicApiRequest(request); const { id } = await context.params; const job = await getGenerationJob(id); - if (!job || job.externalClientId !== client.id) return jsonError("Job not found.", 404); + if (!job || job.ownerId !== publicApiOwnerId(client) || job.externalClientId !== client.id) { + return jsonError("Job not found.", 404); + } if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) { return jsonOk({ job }); } diff --git a/app/api/v1/jobs/[id]/route.ts b/app/api/v1/jobs/[id]/route.ts index 3f0115d..49149c5 100644 --- a/app/api/v1/jobs/[id]/route.ts +++ b/app/api/v1/jobs/[id]/route.ts @@ -1,6 +1,6 @@ import { getGenerationJob } from "@/lib/server/data-store"; import { jsonError, jsonOk } from "@/lib/server/api"; -import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth"; +import { authenticatePublicApiRequest, publicApiOwnerId } from "@/lib/server/public-api-auth"; import { publicApiError } from "@/lib/server/public-api-response"; export const runtime = "nodejs"; @@ -10,7 +10,9 @@ export async function GET(request: Request, context: { params: Promise<{ id: str const client = authenticatePublicApiRequest(request); const { id } = await context.params; const job = await getGenerationJob(id); - if (!job || job.externalClientId !== client.id) return jsonError("Job not found.", 404); + if (!job || job.ownerId !== publicApiOwnerId(client) || job.externalClientId !== client.id) { + return jsonError("Job not found.", 404); + } return jsonOk({ job }); } catch (error) { return publicApiError(error); diff --git a/app/api/v1/jobs/route.ts b/app/api/v1/jobs/route.ts index a59f20b..b82f427 100644 --- a/app/api/v1/jobs/route.ts +++ b/app/api/v1/jobs/route.ts @@ -1,9 +1,9 @@ import { listGenerationJobsFiltered } from "@/lib/server/data-store"; import { jsonOk, readJsonBody } from "@/lib/server/api"; -import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth"; +import { authenticatePublicApiRequest, publicApiOwnerId } from "@/lib/server/public-api-auth"; import { createPublicGenerationJob, type PublicJobCreateBody } from "@/lib/server/public-api-jobs"; import { publicApiError } from "@/lib/server/public-api-response"; -import { DEFAULT_OWNER_ID, requestOrigin } from "@/lib/server/runtime"; +import { requestOrigin } from "@/lib/server/runtime"; import type { GenerationCapability, GenerationStatus } from "@/lib/types"; export const runtime = "nodejs"; @@ -13,7 +13,7 @@ export async function GET(request: Request) { const client = authenticatePublicApiRequest(request); const url = new URL(request.url); const jobs = await listGenerationJobsFiltered({ - ownerId: DEFAULT_OWNER_ID, + ownerId: publicApiOwnerId(client), externalClientId: client.id, status: parseStatus(url.searchParams.get("status")), capability: parseCapability(url.searchParams.get("capability")), diff --git a/app/api/v1/openapi.json/route.ts b/app/api/v1/openapi.json/route.ts index bb58500..b256928 100644 --- a/app/api/v1/openapi.json/route.ts +++ b/app/api/v1/openapi.json/route.ts @@ -50,6 +50,7 @@ export async function GET(request: Request) { required: ["id", "capability", "provider", "status", "createdAt", "updatedAt"], properties: { id: { type: "string", example: "job_mpqe3wtt_12ed738079" }, + ownerId: { type: "string", example: "api:partner-a" }, externalClientId: { type: "string" }, capability: { $ref: "#/components/schemas/GenerationCapability" }, provider: { type: "string", enum: ["volcengine-visual", "evolink", "seedance", "mock"] }, diff --git a/app/assets/page.tsx b/app/assets/page.tsx index 709592c..8754b16 100644 --- a/app/assets/page.tsx +++ b/app/assets/page.tsx @@ -2,6 +2,18 @@ import { AssetManager } from "@/components/asset-manager"; export const dynamic = "force-dynamic"; -export default function AssetsPage() { - return ; +export default async function AssetsPage({ + searchParams +}: { + searchParams?: Promise>; +}) { + const params = await searchParams; + const viewParam = Array.isArray(params?.view) ? params?.view[0] : params?.view; + const taskIdParam = Array.isArray(params?.taskId) ? params?.taskId[0] : params?.taskId; + return ( + + ); } diff --git a/app/globals.css b/app/globals.css index 24d68b2..c3cb5b6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -468,6 +468,811 @@ h3 { color: #9aa8a1; } +.create-workbench-panel { + display: grid; + gap: 16px; + overflow: visible; +} + +.create-center-panel { + min-width: 0; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); +} + +.create-center-head { + display: grid; + gap: 12px; + padding-bottom: 14px; + border-bottom: 1px solid var(--line); +} + +.create-center-head h2 { + margin: 0; + font-size: 15px; + line-height: 1.3; +} + +.create-center-head .create-mode-bar { + padding: 0; + border-bottom: 0; +} + +.create-workbench-layout { + display: grid; + gap: 16px; + align-items: start; +} + +.create-workbench-layout.with-template-column { + grid-template-columns: minmax(320px, 480px) minmax(320px, 1fr) minmax(560px, 640px); +} + +.create-workbench-grid { + display: grid; + gap: 16px; + align-items: start; +} + +.create-main-column { + min-width: 0; +} + +.image-template-column { + --image-template-card-width: 132px; + --image-template-card-height: 176px; + position: sticky; + top: 168px; + display: grid; + grid-template-rows: auto auto auto minmax(0, 1fr); + align-self: start; + gap: 12px; + min-width: 0; + height: var(--template-column-height, auto); + min-height: 0; + max-height: none; + overflow: hidden; + overscroll-behavior: contain; +} + +.create-task-column { + position: sticky; + top: 168px; + align-self: start; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 12px; + min-width: 0; + height: var(--task-column-height, auto); + min-height: 0; + overflow: hidden; + overscroll-behavior: contain; +} + +.create-task-head { + min-height: 38px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.create-task-head h2 { + margin: 0; + font-size: 15px; +} + +.create-task-head span { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.create-task-list { + min-width: 0; + min-height: 0; + display: grid; + align-content: start; + gap: 10px; + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 4px; + scrollbar-gutter: stable; +} + +.create-task-card { + min-width: 0; + display: grid; + grid-template-columns: 84px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfdfd; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.74) inset; +} + +.create-task-thumb { + width: 84px; + height: 64px; + border: 1px solid var(--line); + border-radius: 7px; + background: #e9f0f2; + color: var(--muted); + display: grid; + place-items: center; + overflow: hidden; + padding: 0; + text-align: center; +} + +.create-task-thumb img, +.create-task-thumb video { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.create-task-thumb-placeholder { + display: grid; + grid-template-rows: auto auto; + place-items: center; + gap: 4px; + color: var(--muted-strong); + font-size: 11px; + font-weight: 900; +} + +.create-task-thumb-placeholder.generating { + background: #fff7e4; + color: var(--warning); +} + +.create-task-thumb-button { + cursor: zoom-in; + transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; +} + +.create-task-thumb-button:hover { + border-color: rgba(22, 122, 91, 0.58); + box-shadow: 0 10px 20px rgba(15, 118, 110, 0.14); + transform: translateY(-1px); +} + +.create-task-body { + min-width: 0; + display: grid; + align-content: center; + gap: 7px; +} + +.create-task-body h3 { + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + line-height: 1.35; +} + +.create-task-meta { + min-width: 0; + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 6px; + color: var(--muted); + font-size: 12px; + font-weight: 800; + overflow: hidden; + white-space: nowrap; +} + +.create-task-meta .status { + flex: 0 0 auto; +} + +.create-task-meta span:last-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.create-task-link { + align-self: center; + width: fit-content; + min-height: 30px; + padding: 0 9px; + gap: 5px; + font-size: 12px; + white-space: nowrap; +} + +.task-preview-dialog { + width: min(960px, calc(100vw - 48px)); +} + +.image-template-rail-head { + min-height: 34px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.image-template-rail-head h2 { + margin: 0; + font-size: 15px; +} + +.template-add-button { + width: 100%; + justify-content: center; + gap: 7px; + min-height: 38px; + padding: 0 8px; + font-size: 12px; +} + +.icon-button.active { + border-color: #a8d9cf; + background: #f1fbf8; + color: var(--green-dark); +} + +.image-template-rail-list { + display: grid; + grid-template-columns: repeat(auto-fill, var(--image-template-card-width)); + align-content: start; + justify-content: start; + gap: 9px; + min-width: 0; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 4px; + scrollbar-gutter: stable; +} + +.image-template-rail-card { + position: relative; + width: var(--image-template-card-width); + height: var(--image-template-card-height); + min-width: 0; + border: 1px solid rgba(216, 227, 221, 0.85); + border-radius: 8px; + background: #dfe7e4; + color: var(--ink); + overflow: hidden; + text-align: left; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08); + transition: border-color 180ms ease, background 180ms ease, box-shadow 180ms ease, transform 180ms ease; +} + +.image-template-rail-card:hover, +.image-template-rail-card.active { + border-color: rgba(22, 122, 91, 0.55); + background: #eaf4ef; + box-shadow: 0 16px 32px rgba(15, 23, 42, 0.14), 0 0 0 2px rgba(22, 122, 91, 0.1); + transform: translateY(-1px); +} + +.image-template-rail-card.active { + box-shadow: 0 22px 46px rgba(15, 23, 42, 0.2), 0 0 0 3px rgba(22, 122, 91, 0.22); + transform: translateY(-2px); +} + +.image-template-preview-button, +.image-template-select-button, +.image-template-edit-button { + cursor: pointer; +} + +.image-template-preview-button { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + min-width: 0; + border: 0; + padding: 0; + background: transparent; + color: inherit; + display: grid; + text-align: left; +} + +.image-template-preview-button:focus-visible, +.image-template-select-button:focus-visible, +.image-template-edit-button:focus-visible { + outline: 2px solid #39d977; + outline-offset: 2px; +} + +.image-template-select-button { + grid-column: 1; + grid-row: 2; + width: 100%; + min-width: 0; + min-height: 24px; + border: 1px solid rgba(168, 217, 207, 0.78); + border-radius: 7px; + background: rgba(255, 255, 255, 0.72); + color: var(--green-dark); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 0 7px; + font-size: 10px; + font-weight: 800; + white-space: nowrap; + backdrop-filter: blur(10px) saturate(1.15); + transition: border-color 180ms ease, background 180ms ease, color 180ms ease, box-shadow 180ms ease; +} + +.image-template-select-button:hover, +.image-template-select-button[aria-pressed="true"] { + border-color: var(--green); + background: rgba(22, 122, 91, 0.92); + color: #ffffff; + box-shadow: 0 8px 18px rgba(15, 93, 70, 0.2); +} + +.image-template-card-footer { + position: absolute; + z-index: 2; + left: 7px; + right: 7px; + bottom: 7px; + display: grid; + grid-template-columns: minmax(0, 1fr) 24px; + align-items: start; + align-content: start; + gap: 5px; + min-width: 0; + border: 1px solid rgba(255, 255, 255, 0.48); + border-radius: 8px; + background: rgba(255, 255, 255, 0.46); + box-shadow: 0 12px 26px rgba(15, 23, 42, 0.14); + padding: 6px; + backdrop-filter: blur(14px) saturate(1.18); +} + +.image-template-card-copy { + grid-column: 1 / -1; + min-width: 0; + display: grid; +} + +.image-template-card-copy strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + text-shadow: 0 1px 1px rgba(255, 255, 255, 0.55); +} + +.image-template-card-copy span { + min-width: 0; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + color: #40524b; + font-size: 11px; + line-height: 1.35; +} + +.image-template-edit-button { + grid-column: 2; + grid-row: 2; + position: static; + z-index: 3; + width: 24px; + height: 24px; + border: 1px solid rgba(255, 255, 255, 0.58); + border-radius: 7px; + background: rgba(255, 255, 255, 0.58); + color: var(--ink); + display: grid; + place-items: center; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12); + backdrop-filter: blur(10px) saturate(1.12); +} + +.image-template-edit-button:hover { + border-color: #a8d9cf; + color: var(--green-dark); + background: #f1fbf8; +} + +.image-template-preview { + width: 82px; + aspect-ratio: 1 / 1; + border: 1px solid var(--line); + border-radius: 6px; + background: #e9f0f2; + color: var(--muted); + display: grid; + place-items: center; + overflow: hidden; +} + +.image-template-rail-card .image-template-preview { + width: 100%; + height: 100%; + aspect-ratio: auto; + border: 0; + border-radius: 0; + background: #dfe7e4; +} + +.image-template-preview img { + width: 100%; + height: 100%; + display: block; + object-fit: contain; +} + +.image-template-rail-card .image-template-preview img { + object-fit: cover; +} + +.image-template-rail-list > .template-empty { + grid-column: 1 / -1; +} + +.template-inline-form { + display: grid; + gap: 10px; +} + +.template-inline-form .field { + margin-bottom: 0; +} + +.template-inline-form .field > span { + font-weight: 800; + font-size: 12px; +} + +.template-inline-form textarea { + min-height: 136px; + resize: vertical; +} + +.template-placeholder-tools { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin: 2px 0 7px; +} + +.template-placeholder-tools button { + min-height: 30px; + border: 1px solid #d8e5df; + border-radius: 999px; + background: #ffffff; + color: var(--ink); + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 10px; + font-size: 12px; + font-weight: 800; + cursor: pointer; + transition: border-color 180ms ease, background 180ms ease, color 180ms ease; +} + +.template-placeholder-tools button:hover, +.template-placeholder-tools button:focus-visible { + border-color: #a8d9cf; + background: #f1fbf8; + color: var(--green-dark); +} + +.template-inline-toggle { + min-height: 40px; + margin-bottom: 0; +} + +.template-editor-backdrop { + position: fixed; + inset: 0; + z-index: 40; + display: grid; + place-items: center; + padding: 24px; + background: rgba(15, 23, 42, 0.32); +} + +.template-editor-dialog { + width: min(900px, 100%); + max-height: calc(100dvh - 48px); + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #ffffff; + box-shadow: var(--shadow-strong); + display: flex; + flex-direction: column; +} + +.template-editor-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 58px; + padding: 12px 14px 12px 16px; + border-bottom: 1px solid var(--line); + background: #fbfdfc; +} + +.template-editor-title { + min-width: 0; + display: flex; + align-items: center; + gap: 9px; +} + +.template-editor-mark { + width: 34px; + height: 34px; + border: 1px solid #b7d6c8; + border-radius: 8px; + background: #f1fbf8; + color: var(--green-dark); + display: grid; + place-items: center; + flex: 0 0 auto; +} + +.template-editor-head h2 { + margin: 0; + font-size: 18px; +} + +.template-editor-dialog > .compact-callout { + margin: 12px 16px 0; +} + +.template-editor-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + display: grid; + grid-template-columns: minmax(230px, 0.72fr) minmax(0, 1fr); +} + +.template-editor-preview-pane { + min-width: 0; + display: grid; + align-content: start; + gap: 12px; + padding: 16px; + border-right: 1px solid var(--line); + background: #f7faf9; +} + +.template-preview-pane-head { + min-height: 28px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: var(--ink); + font-size: 12px; + font-weight: 900; +} + +.template-required-pill { + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border-radius: 999px; + background: #fff2e8; + color: #9a4a19; + font-size: 11px; + font-weight: 900; + white-space: nowrap; +} + +.template-required-pill.complete { + background: #dff2e8; + color: var(--green-dark); +} + +.template-editor-form { + padding: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.template-editor-form .template-prompt-field { + grid-column: 1 / -1; +} + +.template-engine-field { + grid-column: 1 / -1; +} + +.template-engine-choice { + width: 100%; + min-height: 42px; +} + +.template-engine-choice button { + flex: 1 1 0; + justify-content: center; +} + +.template-preview-uploader { + min-height: 0; + border: 1px dashed #b8c7c1; + border-radius: 8px; + background: #f8fbfa; + color: var(--muted); + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: start; + gap: 10px; + padding: 12px; + text-align: center; + cursor: pointer; + transition: border-color 180ms ease, background 180ms ease; +} + +.template-preview-uploader:hover, +.template-preview-uploader.has-image { + border-color: #a8d9cf; + background: #f1fbf8; +} + +.template-preview-uploader.uploading { + cursor: wait; +} + +.template-preview-uploader input { + display: none; +} + +.template-preview-uploader-media { + width: 100%; + aspect-ratio: 1 / 1; + border: 1px solid var(--line); + border-radius: 7px; + background: #e9f0f2; + color: var(--muted); + display: grid; + place-items: center; + overflow: hidden; +} + +.template-preview-uploader-media img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.template-preview-meta { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.template-preview-meta span, +.template-placeholder-chip { + min-height: 24px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border: 1px solid #d8e5df; + border-radius: 999px; + background: #ffffff; + color: var(--green-dark); + font-size: 11px; + font-weight: 900; + white-space: nowrap; +} + +.template-placeholder-strip { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: -2px; +} + +.template-editor-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 12px 16px; + border-top: 1px solid var(--line); + background: #fbfdfc; +} + +.template-preview-backdrop { + position: fixed; + inset: 0; + z-index: 42; + display: grid; + place-items: center; + padding: 24px; + background: rgba(15, 23, 42, 0.42); +} + +.template-preview-dialog { + width: min(760px, 100%); + max-height: calc(100dvh - 48px); + border: 1px solid var(--line); + border-radius: var(--radius); + background: #ffffff; + box-shadow: var(--shadow-strong); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.template-preview-dialog-head { + min-height: 58px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px 12px 16px; + border-bottom: 1px solid var(--line); + background: #fbfdfc; +} + +.template-preview-dialog-head h2 { + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 18px; +} + +.template-preview-dialog-media { + flex: 1 1 auto; + min-height: 260px; + display: grid; + place-items: center; + overflow: auto; + padding: 16px; + background: #f7faf9; + color: var(--muted); +} + +.template-preview-dialog-media img { + max-width: 100%; + max-height: min(68dvh, 680px); + display: block; + object-fit: contain; + border-radius: 8px; + border: 1px solid var(--line); + background: #ffffff; +} + +.template-preview-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 12px 16px; + border-top: 1px solid var(--line); + background: #fbfdfc; +} + .prompt-editor-wrap { position: relative; } @@ -478,6 +1283,10 @@ h3 { background: transparent; } +.template-prompt-editor-wrap textarea { + width: 100%; +} + .prompt-token-layer { position: absolute; inset: 0; @@ -549,6 +1358,118 @@ h3 { font-size: 12px; } +.material-draft-panel { + border: 1px solid #a8d9cf; + border-radius: 12px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 14px 34px rgba(31, 43, 37, 0.16); + color: var(--ink); + display: grid; + gap: 7px; + padding: 8px; + backdrop-filter: blur(10px); +} + +.prompt-editor-wrap .material-draft-panel { + position: absolute; + z-index: 4; + left: 12px; + bottom: 12px; + width: min(440px, calc(100% - 24px)); +} + +.template-prompt-editor-wrap .material-draft-panel { + bottom: 12px; +} + +.material-draft-entry { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 7px; +} + +.material-draft-prefix { + width: 28px; + height: 28px; + border-radius: 7px; + background: #dff7ec; + color: var(--green-dark); + display: grid; + place-items: center; + font-size: 15px; + font-weight: 900; +} + +.material-draft-entry input { + min-width: 0; + height: 32px; + border: 1px solid var(--line); + border-radius: 7px; + background: #fbfdfc; + color: var(--ink); + padding: 0 9px; + font-size: 13px; + outline: none; +} + +.material-draft-entry input:focus { + border-color: #a8d9cf; + box-shadow: 0 0 0 3px rgba(57, 217, 119, 0.16); +} + +.material-draft-confirm { + height: 32px; + border: 1px solid #b7d6c8; + border-radius: 8px; + background: #f1fbf8; + color: var(--green-dark); + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 10px; + font-size: 12px; + font-weight: 900; + cursor: pointer; +} + +.material-draft-confirm:hover { + border-color: #8fc9b7; + background: #e4f7ef; +} + +.material-draft-options { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.material-draft-option { + min-width: 0; + min-height: 28px; + border: 1px solid #d8e5df; + border-radius: 999px; + background: #ffffff; + color: var(--ink); + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 9px; + font-size: 11px; + font-weight: 800; + cursor: pointer; +} + +.material-draft-option span:not(.chip-token) { + color: var(--muted); +} + +.material-draft-option.active, +.material-draft-option:hover { + border-color: #a8d9cf; + background: #f1fbf8; +} + .upload-icon-button { border: 1px solid var(--line); background: #ffffff; @@ -599,6 +1520,64 @@ h3 { margin-top: 10px; } +.material-placeholder-board { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.material-placeholder-card { + min-width: 0; + min-height: 58px; + border: 1px dashed #b8c7c1; + border-radius: 8px; + background: #f8fbfa; + color: var(--ink); + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + cursor: pointer; +} + +.material-placeholder-card:hover { + border-color: #a8d9cf; + background: #f1fbf8; +} + +.material-placeholder-card.uploading { + cursor: wait; +} + +.material-placeholder-card input { + display: none; +} + +.material-placeholder-icon { + width: 42px; + height: 42px; + border: 1px solid var(--line); + border-radius: 7px; + background: #ffffff; + color: var(--green-dark); + display: grid; + place-items: center; + flex: 0 0 auto; +} + +.material-placeholder-copy { + min-width: 0; + display: grid; + gap: 5px; +} + +.material-placeholder-state { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + .material-card { position: relative; min-width: 0; @@ -737,14 +1716,32 @@ h3 { } .inline-settings { - display: grid; + display: flex; + flex-wrap: wrap; gap: 10px; - align-items: end; + align-items: flex-end; + margin-bottom: 12px; } -.inline-settings { - grid-template-columns: repeat(3, minmax(0, 1fr)); - margin-bottom: 12px; +.inline-settings .inline-field { + flex: 0 0 104px; + width: 104px; + min-width: 0; +} + +.inline-settings .inline-field:nth-child(2) { + flex-basis: 78px; + width: 78px; +} + +.inline-settings .inline-field select { + min-width: 0; +} + +.inline-settings .toggle-line { + flex: 0 0 auto; + min-width: 68px; + padding-inline: 10px; } .inline-field { @@ -1071,7 +2068,7 @@ h3 { } .settings-tabs { - width: min(420px, 100%); + width: min(520px, 100%); } .settings-actions h2, @@ -1124,7 +2121,7 @@ h3 { .settings-service-strip { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; } @@ -1207,6 +2204,25 @@ h3 { gap: 8px; } +.template-empty { + min-height: 78px; + border: 1px dashed var(--line); + border-radius: 8px; + background: #f8fbfb; + color: var(--muted); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px; + font-weight: 800; +} + +.template-empty.compact { + min-height: 52px; + justify-content: flex-start; +} + .log-manager { display: grid; gap: 14px; @@ -1364,6 +2380,160 @@ h3 { font-weight: 800; } +.account-manager { + display: grid; + gap: 14px; +} + +.account-actions { + display: grid; + grid-template-columns: minmax(220px, 1.1fr) minmax(260px, 1fr) minmax(150px, 0.45fr) auto; + align-items: end; + gap: 12px; +} + +.account-org-field { + margin-bottom: 0; +} + +.account-org-field > span, +.account-form-panel .field > span, +.account-department-panel .field > span { + font-weight: 800; + font-size: 13px; +} + +.account-search { + min-height: 42px; +} + +.account-status-select { + min-height: 42px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + color: var(--ink); + padding: 0 12px; +} + +.account-form-panel { + display: grid; + gap: 12px; +} + +.account-department-panel { + display: grid; + gap: 12px; +} + +.account-department-form { + display: grid; + grid-template-columns: minmax(180px, 0.72fr) minmax(240px, 1fr) auto; + align-items: end; + gap: 12px; +} + +.account-department-form .field { + margin-bottom: 0; +} + +.account-department-form .button { + min-height: 42px; + white-space: nowrap; +} + +.account-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + align-items: end; + gap: 12px; +} + +.account-form-grid .field { + margin-bottom: 0; +} + +.account-form-submit { + min-height: 42px; +} + +.account-create-submit { + width: 100%; + min-height: 52px; + justify-content: center; + font-size: 15px; + box-shadow: 0 14px 28px rgba(39, 90, 62, 0.18); +} + +.account-create-submit svg { + width: 19px; + height: 19px; +} + +.account-password-toggle { + min-height: 42px; + margin-bottom: 0; +} + +.account-list { + overflow: hidden; +} + +.account-table { + display: grid; +} + +.account-row { + display: grid; + grid-template-columns: minmax(180px, 1.1fr) minmax(180px, 1fr) minmax(88px, 0.3fr) minmax(230px, auto); + align-items: center; + gap: 12px; + min-height: 64px; + padding: 12px 0; + border-bottom: 1px solid var(--line); +} + +.account-row:last-child { + border-bottom: 0; +} + +.account-head { + min-height: 40px; + padding-top: 0; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.account-member-main { + min-width: 0; + display: grid; + gap: 4px; +} + +.account-member-main strong, +.account-member-main small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-member-main small { + color: var(--muted); + font-size: 12px; +} + +.account-row-actions { + justify-content: flex-end; + gap: 6px; +} + +.danger-icon { + color: #8d2f21; + border-color: #e6b8ae; +} + .image-upload-button { flex: 0 0 auto; } @@ -1812,6 +2982,18 @@ button:active:not(:disabled), min-height: 100dvh; } +.app-shell.create-shell { + height: 100dvh; + min-height: 100dvh; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; +} + +.app-shell.create-shell .topbar { + position: relative; +} + .topbar { min-height: 76px; grid-template-columns: minmax(220px, 1fr) auto minmax(220px, 1fr); @@ -1871,10 +3053,132 @@ button:active:not(:disabled), } .main { - width: min(1280px, calc(100vw - 32px)); + width: min(1760px, calc(100vw - 32px)); padding: 24px 0 40px; } +.main.create-main { + --create-workbench-height: 100%; + width: calc(100vw - 24px); + height: 100%; + min-height: 0; + max-width: none; + padding-top: 14px; + padding-bottom: 14px; + overflow: hidden; + display: grid; +} + +.main.create-main .create-studio:not(.enhance-studio) { + width: 100%; + height: 100%; + min-height: 0; + max-width: none; + margin-inline: 0; +} + +.main.create-main .create-workbench-layout.with-template-column { + grid-template-columns: 462px minmax(0, 1fr) minmax(360px, 480px); + align-items: stretch; + height: var(--create-workbench-height); + min-height: 0; + overflow: hidden; +} + +.main.create-main .image-template-column, +.main.create-main .create-task-column { + position: static; + align-self: stretch; + top: auto; + height: 100%; + max-height: none; +} + +.main.create-main .create-center-panel { + --create-settings-height: 78px; + align-self: stretch; + grid-template-rows: auto minmax(0, 1fr); + height: 100%; + max-height: 100%; + overflow: hidden; +} + +.main.create-main .create-center-panel .create-workbench-grid { + height: 100%; + min-height: 0; + align-items: stretch; + overflow: hidden; + padding-right: 0; + scrollbar-gutter: auto; +} + +.main.create-main .create-main-column { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.main.create-main .create-main-column > .studio-messages { + flex: 0 0 auto; + margin-top: 0; + margin-bottom: 10px; +} + +.main.create-main .prompt-field { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + margin-bottom: 0; +} + +.main.create-main .prompt-field .prompt-editor-wrap { + flex: 1 1 auto; + min-height: 0; +} + +.main.create-main .prompt-field textarea { + height: 100%; + min-height: 0; + max-height: none; + overflow-y: auto; + resize: none; +} + +.main.create-main .material-placeholder-board, +.main.create-main .prompt-material-board { + flex: 0 0 auto; + max-height: 92px; + overflow-y: auto; + overscroll-behavior: contain; +} + +.main.create-main .inline-settings { + flex: 0 0 var(--create-settings-height); + height: var(--create-settings-height); + margin: 0; + padding-top: 10px; + border-top: 1px solid var(--line); + align-items: center; + overflow: hidden; +} + +.main.create-main .inline-settings .inline-field label { + font-size: 12px; +} + +.main.create-main .inline-settings .inline-field select { + min-height: 38px; +} + +.main.create-main .image-template-rail-list { + grid-template-columns: repeat(3, var(--image-template-card-width)); + justify-content: start; +} + .workspace-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -2097,8 +3401,13 @@ button:active:not(:disabled), color: var(--green-dark); } +.compact-callout { + padding: 10px 12px; + font-size: 13px; +} + .create-studio { - max-width: 1060px; + max-width: 1760px; } .create-studio.enhance-studio { @@ -2106,15 +3415,15 @@ button:active:not(:disabled), } .create-mode-bar { - position: sticky; - top: 92px; - z-index: 12; - padding: 10px; - border: 1px solid rgba(215, 224, 228, 0.82); - border-radius: var(--radius); - background: rgba(249, 251, 251, 0.86); - box-shadow: var(--shadow-soft); - backdrop-filter: blur(16px) saturate(1.08); + position: static; + z-index: auto; + padding: 0 0 14px; + border: 0; + border-bottom: 1px solid var(--line); + border-radius: 0; + background: transparent; + box-shadow: none; + backdrop-filter: none; } .mode-switch { @@ -2206,8 +3515,9 @@ button:active:not(:disabled), } .inline-settings { - grid-template-columns: minmax(160px, 0.7fr) minmax(220px, 1fr) minmax(120px, 0.45fr); - align-items: end; + display: flex; + flex-wrap: wrap; + align-items: flex-end; margin-top: 16px; margin-bottom: 0; padding-top: 16px; @@ -2352,6 +3662,81 @@ button:active:not(:disabled), } } +@media (max-width: 1180px) { + .create-workbench-layout.with-template-column { + grid-template-columns: minmax(315px, 375px) minmax(0, 1fr); + } + + .create-task-column { + grid-column: 1 / -1; + position: static; + top: auto; + height: auto; + min-height: 0; + overflow: visible; + } + + .create-task-list { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + overflow: visible; + padding-right: 0; + } +} + +@media (max-width: 1100px) { + .main.create-main .create-workbench-layout.with-template-column { + grid-template-columns: minmax(250px, 0.82fr) minmax(0, 1fr); + align-items: start; + height: 100%; + min-height: 0; + } + + .main.create-main .create-center-panel, + .main.create-main .image-template-column, + .main.create-main .create-task-column { + align-self: start; + height: 100%; + max-height: 100%; + } + + .main.create-main .create-center-panel { + overflow: hidden; + } + + .main.create-main .create-center-panel .create-workbench-grid { + overflow: hidden; + padding-right: 0; + } + + .main.create-main .prompt-field textarea { + height: 100%; + min-height: 0; + max-height: none; + } + + .main.create-main .create-task-column { + grid-column: 1 / -1; + position: static; + top: auto; + height: 100%; + min-height: 0; + overflow: hidden; + } + + .main.create-main .create-task-list { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + overflow: visible; + padding-right: 0; + } +} + +@media (max-width: 1100px) { + .main.create-main .image-template-rail-list { + grid-template-columns: repeat(auto-fill, var(--image-template-card-width)); + justify-content: start; + } +} + @media (max-width: 920px) { .topbar { grid-template-columns: 1fr; @@ -2374,6 +3759,31 @@ button:active:not(:disabled), grid-template-columns: 1fr; } + .create-workbench-layout.with-template-column { + grid-template-columns: minmax(315px, 375px) minmax(0, 1fr); + gap: 12px; + } + + .image-template-column { + top: 12px; + max-height: none; + overflow: hidden; + } + + .image-template-rail-head h2 { + font-size: 13px; + } + + .template-add-button { + gap: 5px; + padding: 0 6px; + font-size: 12px; + } + + .image-template-rail-list { + grid-template-columns: repeat(auto-fill, var(--image-template-card-width)); + } + .job-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2395,6 +3805,21 @@ button:active:not(:disabled), } } +@media (max-width: 720px) { + .main.create-main .create-workbench-layout.with-template-column { + grid-template-columns: 1fr; + } + + .main.create-main .image-template-column { + position: static; + top: auto; + height: auto; + min-height: 0; + max-height: none; + overflow: hidden; + } +} + @media (max-width: 560px) { body { background: var(--bg); @@ -2492,6 +3917,172 @@ button:active:not(:disabled), .settings-service-strip { grid-template-columns: 1fr; } + + .template-editor-backdrop { + align-items: start; + padding: 12px; + } + + .template-editor-dialog { + max-height: calc(100dvh - 24px); + } + + .template-editor-head { + min-height: 54px; + padding: 10px 10px 10px 12px; + } + + .template-editor-body { + grid-template-columns: 1fr; + } + + .template-editor-preview-pane { + border-right: 0; + border-bottom: 1px solid var(--line); + padding: 12px; + } + + .template-editor-form { + grid-template-columns: 1fr; + padding: 12px; + } + + .template-preview-uploader { + grid-template-columns: 82px minmax(0, 1fr); + min-height: 102px; + align-items: center; + text-align: left; + } + + .template-preview-uploader-media { + width: 82px; + } + + .template-editor-form .button.primary { + justify-self: stretch; + } + + .template-editor-actions { + padding: 10px 12px; + } + + .template-editor-actions .button { + flex: 1 1 0; + } + + .template-preview-backdrop { + align-items: start; + padding: 12px; + } + + .template-preview-dialog { + max-height: calc(100dvh - 24px); + } + + .template-preview-dialog-head, + .template-preview-dialog-actions { + padding: 10px 12px; + } + + .template-preview-dialog-media { + min-height: 220px; + padding: 12px; + } + + .template-preview-dialog-actions .button { + flex: 1 1 0; + } + + .create-workbench-layout.with-template-column { + grid-template-columns: 1fr; + gap: 10px; + } + + .create-task-list { + grid-template-columns: 1fr; + } + + .create-task-card { + grid-template-columns: 64px minmax(0, 1fr) auto; + gap: 9px; + } + + .create-task-thumb { + width: 64px; + height: 64px; + } + + .image-template-column { + position: static; + top: auto; + gap: 8px; + min-height: 0; + max-height: none; + overflow: hidden; + } + + .image-template-rail-head { + min-height: 30px; + } + + .image-template-rail-head h2 { + font-size: 12px; + } + + .template-add-button { + gap: 5px; + min-height: 34px; + padding: 0 6px; + font-size: 11px; + } + + .image-template-rail-card { + padding: 0; + } + + .image-template-card-footer { + grid-template-columns: minmax(0, 1fr) 24px; + } + + .image-template-card-copy { + grid-column: 1 / -1; + } + + .image-template-select-button { + width: 100%; + } + + .image-template-card-copy strong { + font-size: 11px; + } + + .image-template-card-copy span { + font-size: 10px; + -webkit-line-clamp: 1; + } + + .image-template-edit-button { + width: 24px; + height: 24px; + } + + .material-draft-entry { + grid-template-columns: auto minmax(0, 1fr); + } + + .material-draft-confirm { + grid-column: 1 / -1; + width: 100%; + } + + .material-draft-option { + width: 100%; + justify-content: flex-start; + } + + .image-template-rail-list { + grid-template-columns: minmax(0, 1fr); + } } /* Compact header pass: keep the shell shorter and avoid horizontal scrollers below it. */ @@ -2623,7 +4214,7 @@ button:active:not(:disabled), } .settings-tabs { - grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); } .asset-view-switch button, @@ -2673,16 +4264,73 @@ button:active:not(:disabled), } .inline-settings { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 12px; padding-top: 12px; } + .inline-settings .inline-field, + .inline-settings .inline-field:nth-child(2), + .inline-settings .toggle-line { + width: auto; + min-width: 0; + } + + .inline-settings .toggle-line { + justify-content: center; + } + .field { margin-bottom: 10px; } } +@media (max-width: 380px) { + .inline-settings { + grid-template-columns: 1fr; + } +} + +@media (max-width: 920px) { + .account-actions, + .account-department-form, + .account-form-grid, + .account-row { + grid-template-columns: 1fr; + } + + .account-head { + display: none; + } + + .account-row { + align-items: start; + gap: 8px; + } + + .account-row-actions { + justify-content: flex-start; + } +} + +@media (max-width: 560px) { + .account-actions { + padding: 8px; + } + + .account-department-form, + .account-form-grid { + gap: 8px; + } + + .account-row-actions .icon-button { + width: 36px; + min-height: 36px; + } +} + .topbar-account { justify-self: end; display: inline-flex; diff --git a/app/layout.tsx b/app/layout.tsx index 329c820..3076a36 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -20,7 +20,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo strategy="beforeInteractive" dangerouslySetInnerHTML={{ __html: randomUUIDPolyfillScript }} /> - {children} + {children} ); diff --git a/app/logs/page.tsx b/app/logs/page.tsx index 7f581e2..fcac528 100644 --- a/app/logs/page.tsx +++ b/app/logs/page.tsx @@ -1,7 +1,9 @@ import { LogManager } from "@/components/log-manager"; +import { requireAdminUser } from "@/lib/server/auth/current-user"; export const dynamic = "force-dynamic"; -export default function LogsPage() { +export default async function LogsPage() { + await requireAdminUser(); return ; } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 1ce6f68..bc4eba2 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,7 +1,9 @@ import { SettingsPanel } from "@/components/settings-panel"; +import { requireAdminUser } from "@/lib/server/auth/current-user"; export const dynamic = "force-dynamic"; -export default function SettingsPage() { +export default async function SettingsPage() { + await requireAdminUser(); return ; } diff --git a/components/account-manager.tsx b/components/account-manager.tsx new file mode 100644 index 0000000..f7218e6 --- /dev/null +++ b/components/account-manager.tsx @@ -0,0 +1,640 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import { CirclePause, KeyRound, Loader2, Pencil, Plus, RefreshCw, Save, Search, Trash2, UserCheck, UserPlus, UserX } from "lucide-react"; +import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; + +type OrganizationInfo = { + organizationId: string; + organizationName: string; +}; + +type OrganizationGroupInfo = { + groupId: string; + groupName: string; +}; + +type OrganizationRoleInfo = { + roleId: string; + roleName: string; +}; + +type OrganizationMemberInfo = { + memberId: string; + memberName: string; + memberStatus: string; + memberPhone: string; + organizationId: string; + memberRoleId?: string; + memberRoleName?: string; + memberGroupId?: string; + memberGroupName?: string; + memberUserId?: string; +}; + +type PageResult = { + records: T[]; + total: number; + size: number; + current: number; + pages: number; +}; + +type AccountPayload = { + configured: boolean; + missing: string[]; + staffConfigured: boolean; + staffMissing: string[]; + selectedOrganizationId: string; + organizations: OrganizationInfo[]; + groups: OrganizationGroupInfo[]; + roles: OrganizationRoleInfo[]; + members: PageResult; + warnings?: string[]; + memberListAvailable?: boolean; + passwordManagementAvailable: boolean; +}; + +type MemberForm = { + organizationId: string; + username: string; + memberName: string; + memberPhone: string; + initialPassword: string; + mustChangePassword: boolean; + roleId: string; + groupId: string; +}; + +type DepartmentForm = { + groupName: string; + groupDesc: string; +}; + +const blankForm: MemberForm = { + organizationId: "", + username: "", + memberName: "", + memberPhone: "", + initialPassword: "", + mustChangePassword: true, + roleId: "", + groupId: "" +}; + +const blankDepartmentForm: DepartmentForm = { + groupName: "", + groupDesc: "" +}; + +const STATUS_OPTIONS = [ + { value: "", label: "全部状态" }, + { value: "0", label: "在岗" }, + { value: "1", label: "休假" }, + { value: "2", label: "停用" } +]; + +export function AccountManager() { + const [payload, setPayload] = useState(null); + const [organizationId, setOrganizationId] = useState(""); + const [status, setStatus] = useState(""); + const [query, setQuery] = useState(""); + const [draftQuery, setDraftQuery] = useState(""); + const [pageNum, setPageNum] = useState(1); + const [form, setForm] = useState(blankForm); + const [departmentForm, setDepartmentForm] = useState(blankDepartmentForm); + const [editingId, setEditingId] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [creatingGroup, setCreatingGroup] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const managerRef = useRef(null); + const listRef = useRef(null); + const feedbackRef = useRef(null); + + const selectedOrganizationId = organizationId || payload?.selectedOrganizationId || ""; + const members = payload?.members.records || []; + const total = payload?.members.total || 0; + const pages = payload?.members.pages || 0; + const memberListAvailable = payload?.memberListAvailable !== false; + const warnings = payload?.warnings || []; + const canSubmit = Boolean(payload?.configured && selectedOrganizationId && form.memberName.trim() && + (editingId || (payload.staffConfigured && form.memberPhone.trim())) && + selectedRoleId(payload, form) && selectedGroupId(payload, form)); + + const currentOrganization = useMemo(() => { + return payload?.organizations.find((item) => item.organizationId === selectedOrganizationId); + }, [payload?.organizations, selectedOrganizationId]); + + useEffect(() => { + void loadAccounts(); + }, [organizationId, pageNum, query, status]); + + useEffect(() => { + return runScopedMotion(managerRef, (scope) => revealChildren(scope)); + }, []); + + useEffect(() => { + crossfadeIn(listRef.current); + }, [members.length, pageNum, query, status, selectedOrganizationId]); + + useEffect(() => { + pulseFeedback(feedbackRef.current); + }, [error, message]); + + async function loadAccounts() { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ + pageNum: String(pageNum), + pageSize: "10" + }); + if (organizationId) params.set("organizationId", organizationId); + if (query) params.set("memberName", query); + if (status) params.set("memberStatus", status); + const response = await fetch(`/api/admin/accounts?${params.toString()}`, { cache: "no-store" }); + const nextPayload = await response.json() as AccountPayload & { error?: string }; + if (!response.ok) throw new Error(nextPayload.error || "读取账号数据失败"); + setPayload(nextPayload); + syncSelectedOrganization(nextPayload); + syncFormDefaults(nextPayload); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + } + + function syncSelectedOrganization(nextPayload: AccountPayload) { + if (!organizationId && nextPayload.selectedOrganizationId) { + setOrganizationId(nextPayload.selectedOrganizationId); + } + } + + function syncFormDefaults(nextPayload: AccountPayload) { + setForm((current) => { + const nextOrganizationId = nextPayload.selectedOrganizationId || current.organizationId; + return { + ...current, + organizationId: nextOrganizationId, + roleId: nextPayload.roles.some((role) => role.roleId === current.roleId) + ? current.roleId + : nextPayload.roles[0]?.roleId || "", + groupId: nextPayload.groups.some((group) => group.groupId === current.groupId) + ? current.groupId + : nextPayload.groups[0]?.groupId || "" + }; + }); + } + + function submitSearch(event: FormEvent) { + event.preventDefault(); + setPageNum(1); + setQuery(draftQuery.trim()); + } + + async function submitMember(event: FormEvent) { + event.preventDefault(); + setSaving(true); + setError(null); + setMessage(null); + try { + const roleId = selectedRoleId(payload, form); + const groupId = selectedGroupId(payload, form); + const body = editingId ? { + memberId: editingId, + memberName: form.memberName.trim(), + roleId, + groupId + } : { + organizationId: selectedOrganizationId, + username: form.username.trim(), + phone: form.memberPhone.trim(), + name: form.memberName.trim(), + initialPassword: form.initialPassword.trim(), + mustChangePassword: form.mustChangePassword, + memberName: form.memberName.trim(), + memberPhone: form.memberPhone.trim(), + roleId, + groupId + }; + const response = await fetch("/api/admin/accounts", { + method: editingId ? "PATCH" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "保存成员失败"); + const initialPassword = result.user?.initialPassword; + const successMessage = editingId + ? "成员信息已更新。" + : initialPassword + ? `成员已创建,初始密码:${initialPassword}` + : "成员已创建。"; + setMessage(!editingId && !memberListAvailable + ? `${successMessage} 成员列表暂不可用,上游授权后可在列表看到。` + : successMessage); + resetForm(); + await loadAccounts(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + async function changeStatus(memberId: string, memberStatus: "0" | "1" | "2") { + await mutateMember("PUT", { memberId, memberStatus }, "成员状态已更新。"); + } + + async function removeMember(memberId: string) { + if (!window.confirm("确定删除这个成员?")) return; + await mutateMember("DELETE", { memberId }, "成员已删除。"); + } + + async function resetPassword(member: OrganizationMemberInfo) { + if (!member.memberUserId) { + setError("该成员还没有绑定企业端用户,不能重置密码。"); + return; + } + const newPassword = window.prompt(`请输入 ${member.memberName} 的新密码`); + if (!newPassword) return; + setSaving(true); + setError(null); + setMessage(null); + try { + const response = await fetch("/api/admin/accounts/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + userId: member.memberUserId, + newPassword, + mustChangePassword: true + }) + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "重置密码失败"); + setMessage("密码已重置,用户下次登录需要修改密码。"); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + async function mutateMember(method: "PUT" | "DELETE", body: Record, successMessage: string) { + setSaving(true); + setError(null); + setMessage(null); + try { + const response = await fetch("/api/admin/accounts", { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "成员操作失败"); + setMessage(successMessage); + await loadAccounts(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + async function submitGroup(event: FormEvent) { + event.preventDefault(); + if (!selectedOrganizationId) { + setError("请先选择组织。"); + return; + } + const groupName = departmentForm.groupName.trim(); + if (!groupName) { + setError("部门名称不能为空。"); + return; + } + setCreatingGroup(true); + setError(null); + setMessage(null); + try { + const response = await fetch("/api/admin/accounts/groups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + organizationId: selectedOrganizationId, + groupName, + groupDesc: departmentForm.groupDesc.trim() + }) + }); + const result = await response.json() as { error?: string; group?: OrganizationGroupInfo | null }; + if (!response.ok) throw new Error(result.error || "创建部门失败"); + setDepartmentForm(blankDepartmentForm); + await loadAccounts(); + if (result.group?.groupId) { + setForm((current) => ({ ...current, groupId: result.group?.groupId || current.groupId })); + } + setMessage("部门已创建。"); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setCreatingGroup(false); + } + } + + function editMember(member: OrganizationMemberInfo) { + setEditingId(member.memberId); + setForm({ + organizationId: member.organizationId || selectedOrganizationId, + username: "", + memberName: member.memberName || "", + memberPhone: member.memberPhone || "", + initialPassword: "", + mustChangePassword: true, + roleId: member.memberRoleId || payload?.roles[0]?.roleId || "", + groupId: member.memberGroupId || payload?.groups[0]?.groupId || "" + }); + } + + function resetForm() { + setEditingId(null); + setForm({ + organizationId: selectedOrganizationId, + username: "", + memberName: "", + memberPhone: "", + initialPassword: "", + mustChangePassword: true, + roleId: payload?.roles[0]?.roleId || "", + groupId: payload?.groups[0]?.groupId || "" + }); + } + + return ( +
+
+
+

账号管理

+
+
+ {payload?.organizations.length || 0} 组织 + {total} 成员 + {payload?.configured ? "已接入" : "待配置"} +
+
+ +
+ +
+
+ + {error || message ? ( +
+ {error ?
{error}
: null} + {message ?
{message}
: null} +
+ ) : null} + + {warnings.length ? ( +
+ {accountWarningText(warnings[0])} +
+ ) : null} + + {!payload?.configured ? ( +
+ 组织接口未配置:需要组织能力网关地址,用于读取组织、部门、角色和成员列表。 +
+ ) : !payload.passwordManagementAvailable ? ( +
+ 企业端用户接口未配置:{payload?.staffMissing.join("、") || "ZHINIAN_STAFF_API_BASE_URL、ZHINIAN_ORG_TENANT_ID"}。配置后可创建企业用户并重置密码。 +
+ ) : null} + +
+
+
+

部门管理

+

{currentOrganization?.organizationName || selectedOrganizationId || "请选择组织"}

+
+
+
+ + + +
+
+ +
+
+
+

{editingId ? "编辑成员" : "新建用户"}

+

{currentOrganization?.organizationName || selectedOrganizationId || "请选择组织"}

+
+ {editingId ? ( + + ) : null} +
+
+ + + + + + + +
+ +
+
+
+ +
+ {loading && !payload ? ( +
+ + 正在读取账号 +
+ ) : members.length ? ( +
+
+ 成员 + 角色 / 部门 + 状态 + 操作 +
+ {members.map((member) => ( +
+
+ {member.memberName} + {member.memberPhone || member.memberId} +
+
+ {member.memberRoleName || "-"} + {member.memberGroupName || "-"} +
+ {memberStatusLabel(member.memberStatus)} +
+ + + + + +
+
+ ))} +
+ ) : ( +
{accountEmptyText(payload)}
+ )} + {pages > 1 ? ( +
+ + {pageNum} / {pages} + +
+ ) : null} +
+
+ ); +} + +function selectedRoleId(payload: AccountPayload | null, form: MemberForm): string { + if (payload?.roles.some((role) => role.roleId === form.roleId)) return form.roleId; + return payload?.roles[0]?.roleId || ""; +} + +function selectedGroupId(payload: AccountPayload | null, form: MemberForm): string { + if (payload?.groups.some((group) => group.groupId === form.groupId)) return form.groupId; + return payload?.groups[0]?.groupId || ""; +} + +function memberStatusLabel(status: string): string { + if (status === "0") return "在岗"; + if (status === "1") return "休假"; + if (status === "2") return "停用"; + return status || "未知"; +} + +function accountEmptyText(payload: AccountPayload | null): string { + if (!payload?.configured) return "组织接口配置后显示成员"; + if (payload.memberListAvailable === false) return "成员列表暂不可用,新增账号仍可使用"; + return "暂无成员"; +} + +function accountWarningText(message: string): string { + if (/仅管理员角色|上游.*管理员|hotelStaff 管理员|管理员角色/i.test(message)) { + return "成员列表需要认证中心管理员角色 1。当前账号已允许进入本平台后台,但上游 hotelStaff 暂未授权成员列表查询。"; + } + if (/成员列表|organizationMemberList|ZHINIAN_ORG_MEMBER_LIST_PATH/i.test(message)) { + return "成员列表待接入:当前组织服务还没有开放成员查询,新增账号仍可使用。"; + } + return message; +} diff --git a/components/app-shell.tsx b/components/app-shell.tsx index 42b372c..c3856dc 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { Archive, + ShieldCheck, LogIn, LogOut, ScrollText, @@ -20,29 +21,33 @@ import type { AuthUser } from "@/lib/auth/session"; const nav = [ { href: "/create", label: "创作", icon: Sparkles }, { href: "/assets", label: "结果", icon: Archive }, - { href: "/logs", label: "日志", icon: ScrollText }, - { href: "/settings", label: "设置", icon: Settings } + { href: "/logs", label: "日志", icon: ScrollText, adminOnly: true }, + { href: "/settings", label: "设置", icon: Settings, adminOnly: true }, + { href: "/accounts", label: "账号", icon: ShieldCheck, adminOnly: true } ]; export function AppShell({ children, user, - authRequired + authRequired, + isAdmin }: { children: React.ReactNode; user?: AuthUser | null; authRequired?: boolean; + isAdmin?: boolean; }) { const pathname = usePathname(); const shellRef = useRef(null); const isAuthPage = pathname.startsWith("/auth"); + const isCreatePage = pathname === "/create"; useEffect(() => { return runScopedMotion(shellRef, (scope) => revealChildren(scope, "[data-shell-animate]")); }, []); return ( -
+
{!isAuthPage ? 跳到主要内容 : null} {!isAuthPage ? (
@@ -55,7 +60,7 @@ export function AppShell({
) : null} -
{children}
+
{children}
); } diff --git a/components/asset-manager.tsx b/components/asset-manager.tsx index 39873ca..a546be2 100644 --- a/components/asset-manager.tsx +++ b/components/asset-manager.tsx @@ -10,16 +10,22 @@ type AssetView = "assets" | "tasks"; const ASSET_PAGE_SIZE = 8; const TASK_PAGE_SIZE = 8; -export function AssetManager() { +type AssetManagerProps = { + initialView?: AssetView; + initialTaskId?: string; +}; + +export function AssetManager({ initialView = "assets", initialTaskId }: AssetManagerProps = {}) { const [assets, setAssets] = useState([]); const [jobs, setJobs] = useState([]); - const [view, setView] = useState("assets"); + const [view, setView] = useState(initialView === "tasks" ? "tasks" : "assets"); const [loading, setLoading] = useState(false); const [syncingJobId, setSyncingJobId] = useState(null); const [deletingAssetId, setDeletingAssetId] = useState(null); const [deletingJobId, setDeletingJobId] = useState(null); const [previewAsset, setPreviewAsset] = useState(null); - const [expandedJobId, setExpandedJobId] = useState(null); + const [expandedJobId, setExpandedJobId] = useState(initialTaskId || null); + const [durationNow, setDurationNow] = useState(() => Date.now()); const [assetPage, setAssetPage] = useState(1); const [jobPage, setJobPage] = useState(1); const [error, setError] = useState(null); @@ -33,6 +39,11 @@ export function AssetManager() { refresh().catch(() => undefined); }, []); + useEffect(() => { + if (initialView === "tasks" || initialTaskId) setView("tasks"); + if (initialTaskId) setExpandedJobId(initialTaskId); + }, [initialTaskId, initialView]); + const jobByOutputAssetId = useMemo(() => { const map = new Map(); for (const job of jobs) { @@ -43,6 +54,7 @@ export function AssetManager() { const visibleAssets = pageItems(assets, assetPage, ASSET_PAGE_SIZE); const visibleJobs = pageItems(jobs, jobPage, TASK_PAGE_SIZE); + const hasLiveJobs = useMemo(() => jobs.some((job) => !isTerminalStatus(job.status)), [jobs]); useEffect(() => { setAssetPage((page) => clampPage(page, assets.length, ASSET_PAGE_SIZE)); @@ -57,6 +69,12 @@ export function AssetManager() { if (view === "tasks") setJobPage((page) => clampPage(page, jobs.length, TASK_PAGE_SIZE)); }, [view, assets.length, jobs.length]); + useEffect(() => { + if (!expandedJobId) return; + const index = jobs.findIndex((job) => job.id === expandedJobId); + if (index >= 0) setJobPage(Math.floor(index / TASK_PAGE_SIZE) + 1); + }, [expandedJobId, jobs]); + useEffect(() => { return runScopedMotion(managerRef, (scope) => revealChildren(scope)); }, []); @@ -69,6 +87,15 @@ export function AssetManager() { pulseFeedback(feedbackRef.current); }, [error]); + useEffect(() => { + if (!hasLiveJobs) return undefined; + setDurationNow(Date.now()); + const timer = window.setInterval(() => { + setDurationNow(Date.now()); + }, 1000); + return () => window.clearInterval(timer); + }, [hasLiveJobs]); + useEffect(() => { if (!previewAsset) return undefined; modalEnter(previewDialogRef.current); @@ -234,7 +261,7 @@ export function AssetManager() {

{job.prompt?.slice(0, 90) || capabilityLabel(job.capability)}

{capabilityLabel(job.capability)} / {job.reqKey}

-

{durationLabel(job)} / 输入 {job.inputAssetIds.length || job.inputUrls.length} 个,输出 {job.outputAssetIds.length} 个

+

{durationLabel(job, durationNow)} / 输入 {job.inputAssetIds.length || job.inputUrls.length} 个,输出 {job.outputAssetIds.length} 个

{statusLabel(job.status)} @@ -250,7 +277,7 @@ export function AssetManager() {
- {expandedJobId === job.id ? : null} + {expandedJobId === job.id ? : null} ))} {!jobs.length ?
暂无生成任务。提交图片或视频生成后会保留任务历史。
: null} @@ -286,7 +313,7 @@ export function AssetManager() { ); } -function JobDetails({ job }: { job: GenerationJob }) { +function JobDetails({ job, now }: { job: GenerationJob; now: number }) { return (
@@ -296,7 +323,7 @@ function JobDetails({ job }: { job: GenerationJob }) {
耗时
-
{durationValue(job)}
+
{durationValue(job, now)}
提交时间
@@ -435,6 +462,10 @@ function statusLabel(status: GenerationJob["status"]) { return status; } +function isTerminalStatus(status: GenerationJob["status"]) { + return status === "succeeded" || status === "failed" || status === "expired" || status === "cancelled"; +} + function providerLabel(provider: GenerationJob["provider"]) { if (provider === "volcengine-visual") return "火山视觉"; if (provider === "evolink") return "EvoLink"; @@ -473,15 +504,15 @@ function formatDateTime(value: string) { }).format(new Date(value)); } -function durationLabel(job: GenerationJob) { - const terminal = ["succeeded", "failed", "expired", "cancelled"].includes(job.status); - return `${terminal ? "耗时" : "已等待"} ${durationValue(job)}`; +function durationLabel(job: GenerationJob, now: number) { + const terminal = isTerminalStatus(job.status); + return `${terminal ? "耗时" : "已耗时"} ${durationValue(job, now)}`; } -function durationValue(job: GenerationJob) { +function durationValue(job: GenerationJob, now: number) { const start = new Date(job.createdAt).getTime(); - const terminal = ["succeeded", "failed", "expired", "cancelled"].includes(job.status); - const end = terminal ? new Date(job.updatedAt).getTime() : Date.now(); + const terminal = isTerminalStatus(job.status); + const end = terminal ? new Date(job.updatedAt).getTime() : now; if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return "--"; return formatDuration(end - start); } diff --git a/components/create-studio.tsx b/components/create-studio.tsx index 4f7d3f3..e12669c 100644 --- a/components/create-studio.tsx +++ b/components/create-studio.tsx @@ -1,14 +1,22 @@ "use client"; -import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; -import { Film, ImagePlus, ImageUp, Loader2, Music, Paintbrush, Send, Upload, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"; +import { Check, Download, ExternalLink, Film, ImageIcon, ImagePlus, ImageUp, Loader2, Music, Paintbrush, Pencil, Plus, RefreshCw, Save, Send, Upload, X } from "lucide-react"; import clsx from "clsx"; import { ImageEditor, type ImageEditMode } from "@/components/image-editor"; import { clampPage, pageItems, Pagination } from "@/components/pagination"; import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; import { VIDEO_DURATION_DEFAULT, VIDEO_DURATION_OPTIONS, VIDEO_RATIOS, VIDEO_RESOLUTIONS, clampVideoDuration } from "@/lib/video-settings"; -import type { Asset } from "@/lib/types"; +import type { Asset, GenerationJob } from "@/lib/types"; import type { PromptMaterial } from "@/lib/prompt/assembler"; +import { + detectMaterialDraftStart, + insertMaterialDraftToken, + nextMaterialDraftToken, + normalizeMaterialDraftToken, + type MaterialDraftKind +} from "@/lib/prompt/material-draft"; +import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders"; type GenerateMode = "image" | "video"; type StudioMode = GenerateMode | ImageEditMode; @@ -20,11 +28,55 @@ type MentionState = { query: string; }; +type MaterialDraftTarget = "prompt" | "template"; + +type MaterialDraftState = { + target: MaterialDraftTarget; + index: number; + query: string; + activeIndex: number | null; +}; + +type MaterialDraftOption = { + token: string; + label: string; + caption: string; + type: MaterialKind; +}; + type HealthCapability = { id: string; engine?: string; }; +type ImageTemplate = { + id: string; + name: string; + prompt: string; + previewImageUrl?: string; + settings: { + engine?: ImageGenerateEngine; + width?: number; + height?: number; + forceSingle?: boolean; + scale?: number; + quality?: string; + }; + sortOrder: number; + updatedAt?: string; +}; + +type TemplateForm = { + name: string; + prompt: string; + previewImageUrl: string; + engine: ImageGenerateEngine; + jimengInfluence: string; + evolinkQuality: string; + size: string; + sortOrder: string; +}; + const jimengInfluenceOptions = [ { id: "creative", label: "创意 35", scale: 35 }, { id: "balanced", label: "均衡 50", scale: 50 }, @@ -45,8 +97,30 @@ const imageSizePresets = [ { label: "9:16", width: 1440, height: 2560 } ]; +type ImageSizePreset = (typeof imageSizePresets)[number]; + +type TemplateConsoleSnapshot = { + mode: GenerateMode; + imagePrompt: string; + imageSize: ImageSizePreset; + imageEngine: ImageGenerateEngine; + jimengInfluence: string; + evolinkQuality: string; +}; + const materialUploadAccept = "image/*,video/*,audio/*"; +const templatePreviewAccept = "image/*"; const MATERIAL_PAGE_SIZE = 6; +const defaultTemplateForm: TemplateForm = { + name: "", + prompt: "", + previewImageUrl: "", + engine: "jimeng", + jimengInfluence: jimengInfluenceOptions[1].id, + evolinkQuality: evolinkQualityOptions[1].id, + size: imageSizePresets[0].label, + sortOrder: "0" +}; export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMode }) { const [mode, setMode] = useState(initialMode); @@ -55,6 +129,23 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo video: "" }); const [materials, setMaterials] = useState([]); + const [imageTemplates, setImageTemplates] = useState([]); + const [taskAssets, setTaskAssets] = useState([]); + const [recentJobs, setRecentJobs] = useState([]); + const [templatesLoading, setTemplatesLoading] = useState(false); + const [tasksLoading, setTasksLoading] = useState(false); + const [templateSaving, setTemplateSaving] = useState(false); + const [templatePreviewUploading, setTemplatePreviewUploading] = useState(false); + const [templateError, setTemplateError] = useState(null); + const [tasksError, setTasksError] = useState(null); + const [activeTemplateId, setActiveTemplateId] = useState(null); + const [templateEditorOpen, setTemplateEditorOpen] = useState(false); + const [editingTemplateId, setEditingTemplateId] = useState(null); + const [previewTemplate, setPreviewTemplate] = useState(null); + const [previewTaskAsset, setPreviewTaskAsset] = useState(null); + const [durationNow, setDurationNow] = useState(() => Date.now()); + const [templateForm, setTemplateForm] = useState(defaultTemplateForm); + const [templateRestoreState, setTemplateRestoreState] = useState(null); const [busy, setBusy] = useState(false); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); @@ -63,36 +154,73 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo const [imageEngine, setImageEngine] = useState("jimeng"); const [jimengInfluence, setJimengInfluence] = useState(jimengInfluenceOptions[1].id); const [evolinkQuality, setEvolinkQuality] = useState(evolinkQualityOptions[1].id); - const [forceSingle, setForceSingle] = useState(true); const [videoRatio, setVideoRatio] = useState("9:16"); const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT); const [videoResolution, setVideoResolution] = useState("720p"); const [mentionState, setMentionState] = useState(null); + const [materialDraft, setMaterialDraft] = useState(null); const [activeMentionIndex, setActiveMentionIndex] = useState(null); const [promptScrollTop, setPromptScrollTop] = useState(0); + const [templatePromptScrollTop, setTemplatePromptScrollTop] = useState(0); const [materialPage, setMaterialPage] = useState(1); const studioRef = useRef(null); const modePanelRef = useRef(null); const feedbackRef = useRef(null); const materialBoardRef = useRef(null); const promptRef = useRef(null); + const templatePromptRef = useRef(null); + const materialDraftInputRef = useRef(null); const isImageEditMode = mode === "inpaint" || mode === "upscale"; const generateMode: GenerateMode = mode === "video" ? "video" : "image"; const prompt = promptByMode[generateMode]; const selectedJimengInfluence = jimengInfluenceOptions.find((option) => option.id === jimengInfluence) || jimengInfluenceOptions[1]; const selectedEvolinkQuality = evolinkQualityOptions.find((option) => option.id === evolinkQuality) || evolinkQualityOptions[1]; + const selectedTemplateJimengInfluence = jimengInfluenceOptions.find((option) => option.id === templateForm.jimengInfluence) || jimengInfluenceOptions[1]; + const selectedTemplateEvolinkQuality = evolinkQualityOptions.find((option) => option.id === templateForm.evolinkQuality) || evolinkQualityOptions[1]; + const templateFormReady = Boolean(templateForm.name.trim() && templateForm.prompt.trim() && templateForm.previewImageUrl.trim()); + const templateFormPlaceholders = useMemo(() => extractMaterialPlaceholders(templateForm.prompt), [templateForm.prompt]); + const materialPlaceholders = useMemo(() => extractMaterialPlaceholders(prompt), [prompt]); + const referencedMaterialLabels = useMemo(() => new Set(materialPlaceholders.map((placeholder) => placeholder.token)), [materialPlaceholders]); + const missingMaterialPlaceholders = useMemo(() => { + const existing = new Set(materials.map((material) => normalizeMaterialLabel(material.label)).filter((label): label is string => Boolean(label))); + return materialPlaceholders.filter((placeholder) => !existing.has(placeholder.token)); + }, [materialPlaceholders, materials]); + const submitDisabled = busy || !prompt.trim() || missingMaterialPlaceholders.length > 0; + const submitTitle = missingMaterialPlaceholders.length + ? `请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}` + : generateMode === "image" ? "生成图片" : "生成视频"; const visibleMaterials = pageItems(materials, materialPage, MATERIAL_PAGE_SIZE); const materialPageOffset = (clampPage(materialPage, materials.length, MATERIAL_PAGE_SIZE) - 1) * MATERIAL_PAGE_SIZE; const mentionSuggestions = useMemo(() => { if (!mentionState) return []; return materials.filter((material) => materialMatchesMention(material, mentionState.query)).slice(0, 8); }, [materials, mentionState]); + const materialDraftOptions = useMemo(() => { + if (!materialDraft) return []; + return buildMaterialDraftOptions({ + target: materialDraft.target, + query: materialDraft.query, + prompt: materialDraft.target === "template" ? templateForm.prompt : prompt, + materials + }); + }, [materialDraft, materials, prompt, templateForm.prompt]); + const taskAssetById = useMemo(() => { + const map = new Map(); + for (const asset of taskAssets) map.set(asset.id, asset); + return map; + }, [taskAssets]); + const hasLiveTasks = useMemo(() => recentJobs.some((job) => !isTerminalStatus(job.status)), [recentJobs]); useEffect(() => { setActiveMentionIndex(null); }, [mentionState?.query, mentionSuggestions.length]); + useEffect(() => { + if (!materialDraft) return; + window.requestAnimationFrame(() => materialDraftInputRef.current?.focus()); + }, [materialDraft?.target, materialDraft?.index]); + useEffect(() => { setMaterialPage((page) => clampPage(page, materials.length, MATERIAL_PAGE_SIZE)); }, [materials.length]); @@ -127,11 +255,99 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo }; }, []); + useEffect(() => { + let active = true; + void loadImageTemplates(() => active); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + let active = true; + void loadTaskModuleData(() => active); + const timer = window.setInterval(() => { + if (document.visibilityState === "visible") { + void loadTaskModuleData(() => active, { silent: true }); + } + }, 15000); + return () => { + active = false; + window.clearInterval(timer); + }; + }, []); + + useEffect(() => { + if (!hasLiveTasks) return undefined; + setDurationNow(Date.now()); + const timer = window.setInterval(() => { + setDurationNow(Date.now()); + }, 1000); + return () => window.clearInterval(timer); + }, [hasLiveTasks]); + + async function loadImageTemplates(isActive: () => boolean = () => true) { + setTemplatesLoading(true); + setTemplateError(null); + try { + const response = await fetch("/api/image-templates", { cache: "no-store" }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "读取模板失败"); + const nextTemplates = ((payload.templates || []) as ImageTemplate[]).sort(sortTemplates); + if (isActive()) { + setImageTemplates(nextTemplates); + } + } catch (err) { + if (isActive()) setTemplateError(err instanceof Error ? err.message : String(err)); + } finally { + if (isActive()) setTemplatesLoading(false); + } + } + + async function loadTaskModuleData(isActive: () => boolean = () => true, options: { silent?: boolean } = {}) { + if (!options.silent) setTasksLoading(true); + setTasksError(null); + try { + const [assetResponse, imageResponse, videoResponse] = await Promise.all([ + fetch("/api/assets", { cache: "no-store" }), + fetch("/api/generations/image", { cache: "no-store" }), + fetch("/api/generations/video", { cache: "no-store" }) + ]); + const [assetPayload, imagePayload, videoPayload] = await Promise.all([ + assetResponse.json(), + imageResponse.json(), + videoResponse.json() + ]); + if (!assetResponse.ok) throw new Error(assetPayload.error || "读取资产失败"); + if (!imageResponse.ok) throw new Error(imagePayload.error || "读取图片任务失败"); + if (!videoResponse.ok) throw new Error(videoPayload.error || "读取视频任务失败"); + const dedupedJobs = new Map(); + for (const job of [...(imagePayload.jobs || []), ...(videoPayload.jobs || [])] as GenerationJob[]) { + dedupedJobs.set(job.id, job); + } + const nextJobs = [...dedupedJobs.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + if (isActive()) { + setTaskAssets((assetPayload.assets || []) as Asset[]); + setRecentJobs(nextJobs); + } + } catch (err) { + if (isActive()) setTasksError(err instanceof Error ? err.message : String(err)); + } finally { + if (isActive() && !options.silent) setTasksLoading(false); + } + } + function setPrompt(value: string) { setPromptByMode((items) => ({ ...items, [generateMode]: value })); } function handlePromptInput(value: string, cursor: number) { + const draft = detectMaterialDraftStart(prompt, value, cursor); + if (draft) { + setPrompt(draft.text); + startMaterialDraft("prompt", draft.index); + return; + } setPrompt(value); updateMention(value, cursor); } @@ -140,24 +356,46 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo setMentionState(findMentionAtCursor(value, cursor)); } - async function uploadFiles(files: FileList | null) { - if (!files?.length) return; + async function uploadAssetFiles(files: File[]): Promise { + const formData = new FormData(); + for (const file of files) formData.append("files", file); + const response = await fetch("/api/assets/upload", { + method: "POST", + body: formData + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "上传失败"); + return payload.assets as Asset[]; + } + + async function uploadFiles(files: FileList | null, options: { targetLabel?: string; expectedType?: MaterialKind } = {}) { + const selectedFiles = Array.from(files || []); + const expectedType = options.expectedType; + if (!selectedFiles.length) return; + if (expectedType && selectedFiles.some((file) => !fileMatchesMaterialKind(file, expectedType))) { + setError(`请上传${shortTypeName(expectedType)}素材。`); + return; + } setUploading(true); setError(null); setNotice(null); try { - const formData = new FormData(); - for (const file of Array.from(files)) formData.append("files", file); - const response = await fetch("/api/assets/upload", { - method: "POST", - body: formData - }); - const payload = await response.json(); - if (!response.ok) throw new Error(payload.error || "上传失败"); - const uploaded = payload.assets as Asset[]; + const uploaded = await uploadAssetFiles(selectedFiles); + if (expectedType && uploaded.some((asset) => materialTypeForAsset(asset) !== expectedType)) { + throw new Error(`请上传${shortTypeName(expectedType)}素材。`); + } setMaterials((items) => { const next = [...items]; - for (const asset of uploaded) next.push(materialFromAsset(asset, next)); + uploaded.forEach((asset, index) => { + const targetLabel = index === 0 ? normalizeMaterialLabel(options.targetLabel) : undefined; + const material = materialFromAsset(asset, next, targetLabel); + const existingIndex = targetLabel ? next.findIndex((item) => normalizeMaterialLabel(item.label) === targetLabel) : -1; + if (existingIndex >= 0) { + next[existingIndex] = material; + return; + } + next.push(material); + }); return next; }); } catch (err) { @@ -167,21 +405,46 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo } } - function materialFromAsset(asset: Asset, existing: PromptMaterial[]): PromptMaterial { + async function uploadTemplatePreview(files: FileList | null) { + const file = files?.[0]; + if (!file) return; + if (!fileMatchesMaterialKind(file, "image")) { + setTemplateError("模板预览图只能上传图片。"); + return; + } + setTemplatePreviewUploading(true); + setTemplateError(null); + try { + const [asset] = await uploadAssetFiles([file]); + setTemplateForm((form) => ({ + ...form, + previewImageUrl: asset.url, + name: form.name || asset.name.replace(/\.[^.]+$/, "") + })); + } catch (err) { + setTemplateError(err instanceof Error ? err.message : String(err)); + } finally { + setTemplatePreviewUploading(false); + } + } + + function materialFromAsset(asset: Asset, existing: PromptMaterial[], preferredLabel?: string): PromptMaterial { const type = materialTypeForAsset(asset); - const currentCount = existing.filter((material) => material.type === type).length; + const normalizedPreferredLabel = normalizeMaterialLabel(preferredLabel); return { id: asset.id, url: asset.url, type, role: "upload", - label: labelFor(type, currentCount + 1), + label: normalizedPreferredLabel && materialTypeForLabel(normalizedPreferredLabel) === type + ? normalizedPreferredLabel + : nextMaterialLabel(type, existing), name: asset.name }; } function removeMaterial(index: number) { - setMaterials((items) => relabel(items.filter((_, itemIndex) => itemIndex !== index))); + setMaterials((items) => items.filter((_, itemIndex) => itemIndex !== index)); } function insertToken(token?: string) { @@ -212,6 +475,223 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo }); } + function applyImageTemplate(template: ImageTemplate) { + if (activeTemplateId === template.id) { + clearImageTemplateSelection(); + return; + } + if (!templateRestoreState) { + setTemplateRestoreState({ + mode: generateMode, + imagePrompt: promptByMode.image, + imageSize, + imageEngine, + jimengInfluence, + evolinkQuality + }); + } + applyTemplateToConsole(template, `已应用模板:${template.name}`); + } + + function clearImageTemplateSelection() { + const snapshot = templateRestoreState; + setActiveTemplateId(null); + setTemplateRestoreState(null); + setMaterialDraft(null); + setMentionState(null); + setPromptScrollTop(0); + setError(null); + if (snapshot) { + setMode(snapshot.mode); + setPromptByMode((items) => ({ ...items, image: snapshot.imagePrompt })); + setImageSize(snapshot.imageSize); + setImageEngine(snapshot.imageEngine); + setJimengInfluence(snapshot.jimengInfluence); + setEvolinkQuality(snapshot.evolinkQuality); + } else { + setPromptByMode((items) => ({ ...items, image: "" })); + setImageSize(imageSizePresets[0]); + setImageEngine("jimeng"); + setJimengInfluence(jimengInfluenceOptions[1].id); + setEvolinkQuality(evolinkQualityOptions[1].id); + } + setNotice("已取消模板选择"); + } + + function applyTemplateToConsole(template: ImageTemplate, message: string) { + setMode("image"); + setPromptByMode((items) => ({ ...items, image: template.prompt })); + setActiveTemplateId(template.id); + setMaterialDraft(null); + const templateEngine = normalizeImageEngine(template.settings.engine); + if (templateEngine) setImageEngine(templateEngine); + const preset = imageSizePresets.find((item) => item.width === template.settings.width && item.height === template.settings.height); + if (preset) setImageSize(preset); + const templateInfluence = jimengInfluenceOptions.find((option) => option.scale === template.settings.scale); + if (templateInfluence) setJimengInfluence(templateInfluence.id); + const templateQuality = evolinkQualityOptions.find((option) => option.quality === template.settings.quality); + if (templateQuality) setEvolinkQuality(templateQuality.id); + setMentionState(null); + setPromptScrollTop(0); + setNotice(message); + setError(null); + } + + function resetTemplateForm() { + setTemplateForm(defaultTemplateFormForEngine(imageEngine)); + setEditingTemplateId(null); + setTemplateError(null); + setMaterialDraft(null); + setTemplatePromptScrollTop(0); + } + + function openTemplateCreator() { + resetTemplateForm(); + setTemplateEditorOpen(true); + } + + function openTemplateEditor(template: ImageTemplate) { + setTemplateForm(formFromTemplate(template)); + setEditingTemplateId(template.id); + setTemplateError(null); + setMaterialDraft(null); + setTemplatePromptScrollTop(0); + setTemplateEditorOpen(true); + } + + function closeTemplateEditor() { + setTemplateEditorOpen(false); + setEditingTemplateId(null); + setTemplateError(null); + setMaterialDraft(null); + setTemplatePromptScrollTop(0); + } + + async function saveTemplate() { + const isEditing = Boolean(editingTemplateId); + const endpoint = editingTemplateId ? `/api/image-templates/${encodeURIComponent(editingTemplateId)}` : "/api/image-templates"; + setTemplateSaving(true); + setTemplateError(null); + try { + const response = await fetch(endpoint, { + method: isEditing ? "PATCH" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(templatePayloadFromForm(templateForm)) + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "保存模板失败"); + const saved = payload.template as ImageTemplate; + setImageTemplates((items) => { + const rest = items.filter((item) => item.id !== saved.id); + return [...rest, saved].sort(sortTemplates); + }); + setTemplateForm(formFromTemplate(saved)); + setEditingTemplateId(null); + setTemplateEditorOpen(false); + applyTemplateToConsole(saved, isEditing ? "模板已更新并应用。" : "模板已保存并应用。"); + } catch (err) { + setTemplateError(err instanceof Error ? err.message : String(err)); + } finally { + setTemplateSaving(false); + } + } + + function insertTemplatePlaceholder(type: MaterialKind) { + const placeholders = extractMaterialPlaceholders(templateForm.prompt).filter((placeholder) => placeholder.type === type); + const nextIndex = placeholders.reduce((max, placeholder) => Math.max(max, placeholder.index), 0) + 1; + const token = labelFor(type, nextIndex); + const textarea = templatePromptRef.current; + const value = templateForm.prompt; + if (!textarea) { + const joiner = value.endsWith("\n") || !value.trim() ? "" : " "; + setTemplateForm((form) => ({ ...form, prompt: `${value}${joiner}${token} ` })); + return; + } + const start = textarea.selectionStart ?? value.length; + const end = textarea.selectionEnd ?? value.length; + const before = value.slice(0, start); + const after = value.slice(end); + const needsLeadingSpace = before.length > 0 && !/[\s,。;:、((【\[]$/.test(before); + const insertion = `${needsLeadingSpace ? " " : ""}${token} `; + const nextPrompt = `${before}${insertion}${after}`; + const nextCursor = before.length + insertion.length; + setTemplateForm((form) => ({ ...form, prompt: nextPrompt })); + window.requestAnimationFrame(() => { + textarea.focus(); + textarea.setSelectionRange(nextCursor, nextCursor); + }); + } + + function startMaterialDraft(target: MaterialDraftTarget, index: number) { + setMentionState(null); + setMaterialDraft({ target, index, query: "", activeIndex: null }); + } + + function updateMaterialDraftQuery(value: string) { + setMaterialDraft((draft) => draft ? { ...draft, query: value.replace(/^@+/, ""), activeIndex: null } : draft); + } + + function confirmMaterialDraft(option?: MaterialDraftOption) { + if (!materialDraft) return; + const sourcePrompt = materialDraft.target === "template" ? templateForm.prompt : prompt; + const token = option?.token || normalizeMaterialDraftToken(materialDraft.query, sourcePrompt); + if (!token) return; + const next = insertMaterialDraftToken(sourcePrompt, materialDraft.index, token); + const target = materialDraft.target; + setMaterialDraft(null); + setMentionState(null); + if (target === "template") { + setTemplateForm((form) => ({ ...form, prompt: next.text })); + window.requestAnimationFrame(() => { + templatePromptRef.current?.focus(); + templatePromptRef.current?.setSelectionRange(next.cursor, next.cursor); + }); + return; + } + setPrompt(next.text); + window.requestAnimationFrame(() => { + promptRef.current?.focus(); + promptRef.current?.setSelectionRange(next.cursor, next.cursor); + }); + } + + function handleMaterialDraftKeyDown(event: KeyboardEvent) { + if (!materialDraft) return; + if (event.key === "Escape") { + event.preventDefault(); + const target = materialDraft.target; + setMaterialDraft(null); + window.requestAnimationFrame(() => { + (target === "template" ? templatePromptRef.current : promptRef.current)?.focus(); + }); + return; + } + if (event.key === "ArrowDown" && materialDraftOptions.length) { + event.preventDefault(); + setMaterialDraft((draft) => draft ? { ...draft, activeIndex: draft.activeIndex === null ? 0 : (draft.activeIndex + 1) % materialDraftOptions.length } : draft); + return; + } + if (event.key === "ArrowUp" && materialDraftOptions.length) { + event.preventDefault(); + setMaterialDraft((draft) => draft ? { ...draft, activeIndex: draft.activeIndex === null ? materialDraftOptions.length - 1 : (draft.activeIndex - 1 + materialDraftOptions.length) % materialDraftOptions.length } : draft); + return; + } + if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault(); + confirmMaterialDraft(materialDraftOptions[materialDraft.activeIndex ?? 0]); + } + } + + function handleTemplatePromptInput(value: string, cursor: number) { + const draft = detectMaterialDraftStart(templateForm.prompt, value, cursor); + if (draft) { + setTemplateForm((form) => ({ ...form, prompt: draft.text })); + startMaterialDraft("template", draft.index); + return; + } + setTemplateForm((form) => ({ ...form, prompt: value })); + } + function selectMention(material: PromptMaterial) { if (!material.label) return; const textarea = promptRef.current; @@ -254,6 +734,10 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo async function submit() { if (isImageEditMode) return; + if (missingMaterialPlaceholders.length) { + setError(`请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}。`); + return; + } setBusy(true); setError(null); setNotice(null); @@ -262,6 +746,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo const body = generateMode === "image" ? { capability: "image.generate", + engine: imageEngine, prompt, materials: materials.filter((material) => material.type === "image"), width: imageSize.width, @@ -269,7 +754,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo ...(imageEngine === "evolink" ? { quality: selectedEvolinkQuality.quality } : { scale: selectedJimengInfluence.scale }), - force_single: forceSingle + force_single: true } : { prompt, @@ -287,8 +772,14 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo }); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || "提交生成失败"); + const submittedJob = payload.job as GenerationJob | undefined; + if (submittedJob?.id) { + setRecentJobs((items) => [submittedJob, ...items.filter((item) => item.id !== submittedJob.id)].sort((a, b) => b.createdAt.localeCompare(a.createdAt))); + } + void loadTaskModuleData(() => true, { silent: true }); setNotice(`${generateMode === "image" ? "图片" : "视频"}生成已提交。任务已进入「结果」,生成完成后结果资产会自动保留。`); setPrompt(""); + setActiveTemplateId(null); setMaterials([]); setMaterialPage(1); setMentionState(null); @@ -300,15 +791,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo } } - return ( -
-
-
-

创作生成台

-
-
- -
+ function renderCreateModeBar() { + return ( +
) : null}
+ ); + } + return ( +
{isImageEditMode ? ( -
{ modePanelRef.current = node; }} data-animate> - +
{ modePanelRef.current = node; }} data-animate> +
+ {renderCreateModeBar()} + +
) : ( -
{ modePanelRef.current = node; }} data-animate> +
{ modePanelRef.current = node; }} data-animate> + +
+
+

制作中心

+ {renderCreateModeBar()} +
+
+
{error || notice ? (
@@ -394,7 +951,15 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo onScroll={(event) => setPromptScrollTop(event.currentTarget.scrollTop)} placeholder="输入营销内容提示词,键入 @ 选择已上传素材" /> - {mentionState && mentionSuggestions.length ? ( + {materialDraft?.target === "prompt" ? renderMaterialDraftPanel( + materialDraft, + materialDraftOptions, + materialDraftInputRef, + updateMaterialDraftQuery, + handleMaterialDraftKeyDown, + confirmMaterialDraft + ) : null} + {!materialDraft && mentionState && mentionSuggestions.length ? (
{mentionSuggestions.map((material, index) => (
+ {templateEditorOpen ? ( +
+
+
+
+ +

{editingTemplateId ? "编辑模板" : "添加模板"}

+
+ +
+ {templateError ?
{templateError}
: null} +
+ +
+ +
+ 生图引擎 +
+ + +
+
+ + + +