diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26ad7bc..f255d4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm desktop:release:check + - run: pnpm test:desktop-release - run: pnpm --dir apps/desktop exec electron-forge package --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 26349d6..cf3248b 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -3,14 +3,14 @@ name: Desktop Release on: push: tags: - - "desktop-v*" + - "v*" workflow_dispatch: inputs: version: - description: "版本号或 desktop-v 标签,例如 0.28.0-beta.1" + description: "版本号或 v 标签,例如 v0.28.0、0.28.0-beta.1" required: true source_ref: - description: "构建来源 ref;留空使用 main" + description: "构建来源 ref;留空使用对应 v 标签" required: false default: "" channel: @@ -18,9 +18,9 @@ on: required: true type: choice options: - - beta - stable - default: beta + - beta + default: stable publish: description: "是否创建或更新 GitHub Release;手动运行默认只上传 CI artifact" required: true @@ -31,10 +31,6 @@ permissions: contents: write actions: read -concurrency: - group: desktop-release-${{ github.ref }} - cancel-in-progress: false - env: NODE_VERSION: "22" PNPM_VERSION: "10.8.1" @@ -51,61 +47,20 @@ jobs: source_ref: ${{ steps.identity.outputs.source_ref }} publish: ${{ steps.identity.outputs.publish }} steps: + - name: Checkout release automation + uses: actions/checkout@v4 + - name: Resolve tag, version and channel id: identity - shell: bash env: EVENT_NAME: ${{ github.event_name }} EVENT_TAG: ${{ github.ref_name }} + GITHUB_SHA: ${{ github.sha }} INPUT_VERSION: ${{ github.event.inputs.version || '' }} - INPUT_CHANNEL: ${{ github.event.inputs.channel || 'beta' }} + INPUT_CHANNEL: ${{ github.event.inputs.channel || 'stable' }} INPUT_SOURCE_REF: ${{ github.event.inputs.source_ref || '' }} INPUT_PUBLISH: ${{ github.event.inputs.publish || 'false' }} - run: | - set -euo pipefail - if [[ "$EVENT_NAME" == "push" ]]; then - tag="$EVENT_TAG" - source_ref="${GITHUB_SHA}" - publish=true - else - raw_version="${INPUT_VERSION#desktop-v}" - tag="desktop-v${raw_version}" - source_ref="${INPUT_SOURCE_REF:-main}" - publish="${INPUT_PUBLISH}" - fi - - if [[ ! "$tag" =~ ^desktop-v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then - echo "::error::invalid desktop release tag: $tag" - exit 1 - fi - version="${tag#desktop-v}" - if [[ "$version" == *-* ]]; then - channel=beta - elif [[ "$EVENT_NAME" == push ]]; then - channel=stable - else - channel="${INPUT_CHANNEL:-stable}" - fi - if [[ "$channel" == stable && "$version" == *-* ]]; then - echo "::error::a prerelease version cannot enter stable" - exit 1 - fi - if [[ "$publish" == true && "$channel" == beta && "$version" != *-* ]]; then - echo "::error::a published beta requires a prerelease version" - exit 1 - fi - if [[ "$publish" != true && "$publish" != false ]]; then - echo "::error::publish must be true or false" - exit 1 - fi - - { - echo "tag=$tag" - echo "version=$version" - echo "channel=$channel" - echo "source_ref=$source_ref" - echo "publish=$publish" - } >> "$GITHUB_OUTPUT" + run: node scripts/resolve-desktop-release.mjs build: name: Build ${{ matrix.name }} @@ -164,28 +119,7 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build macOS DMG native dependency - if: matrix.platform == 'darwin' - shell: bash - run: | - set -euo pipefail - for package_name in macos-alias fs-xattr; do - if [[ "$package_name" == macos-alias ]]; then - expected_output=volume.node - else - expected_output=xattr.node - fi - package_json="$(find node_modules/.pnpm -path "*/node_modules/$package_name/package.json" -print -quit)" - if [[ -z "$package_json" ]]; then - echo "::error::$package_name package is missing" - exit 1 - fi - package_dir="$(dirname "$package_json")" - pnpm exec node-gyp rebuild --directory "$package_dir" - test -f "$package_dir/build/Release/$expected_output" - done + run: pnpm install --frozen-lockfile --config.node-linker=hoisted - name: Validate desktop release contract run: pnpm desktop:release:check @@ -202,8 +136,22 @@ jobs: exit 1 } + - name: Verify published source matches release tag + if: needs.resolve.outputs.publish == 'true' + shell: bash + env: + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} + run: | + set -euo pipefail + tag_commit="$(git rev-list -n 1 "$RELEASE_TAG")" + source_commit="$(git rev-parse 'HEAD^{commit}')" + test "$source_commit" = "$tag_commit" || { + echo "::error::published desktop source $source_commit does not match $RELEASE_TAG at $tag_commit" + exit 1 + } + - name: Validate macOS signing secrets - if: matrix.platform == 'darwin' + if: matrix.platform == 'darwin' && needs.resolve.outputs.publish == 'true' shell: bash env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -225,7 +173,7 @@ jobs: fi - name: Import macOS signing certificate - if: matrix.platform == 'darwin' + if: matrix.platform == 'darwin' && needs.resolve.outputs.publish == 'true' shell: bash env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -245,7 +193,7 @@ jobs: echo "CONTENTCLOUD_DESKTOP_MAC_KEYCHAIN=$keychain" >> "$GITHUB_ENV" - name: Validate Windows signing secrets - if: matrix.platform == 'win32' + if: matrix.platform == 'win32' && needs.resolve.outputs.publish == 'true' shell: bash env: WINDOWS_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE }} @@ -256,7 +204,7 @@ jobs: [[ -n "${WINDOWS_SIGNING_CERTIFICATE_PASSWORD:-}" ]] || { echo "::error::missing WINDOWS_SIGNING_CERTIFICATE_PASSWORD"; exit 1; } - name: Prepare Windows signing certificate - if: matrix.platform == 'win32' + if: matrix.platform == 'win32' && needs.resolve.outputs.publish == 'true' shell: bash env: WINDOWS_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE }} @@ -274,20 +222,42 @@ jobs: - name: Build Electron packages shell: bash env: - CONTENTCLOUD_DESKTOP_SIGN: ${{ matrix.signing == 'required' && '1' || '' }} + CONTENTCLOUD_DESKTOP_SIGN: ${{ needs.resolve.outputs.publish == 'true' && matrix.signing == 'required' && '1' || '' }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | set -euo pipefail - pnpm --dir apps/desktop exec electron-forge make \ + pnpm --dir apps/desktop exec electron-forge package \ --platform "${{ matrix.platform }}" \ - --arch "${{ matrix.arch }}" \ - --targets "${{ matrix.forge_targets }}" + --arch "${{ matrix.arch }}" + + FORGE_MAKE_LOG="$RUNNER_TEMP/electron-forge-make-${{ matrix.target }}.log" + run_forge_make() { + set +e + pnpm --dir apps/desktop exec electron-forge make \ + --skip-package \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --targets "${{ matrix.forge_targets }}" 2>&1 | tee "$FORGE_MAKE_LOG" + forge_status=${PIPESTATUS[0]} + set -e + } + + run_forge_make + if [[ "$forge_status" -ne 0 && "${{ matrix.platform }}" == darwin ]] \ + && grep -q "hdiutil detach /Volumes/Content Work OS" "$FORGE_MAKE_LOG" \ + && grep -q "No such file or directory" "$FORGE_MAKE_LOG"; then + echo "DMG volume cleanup raced with hdiutil; retrying Forge make once." + hdiutil detach "/Volumes/Content Work OS" -force || true + sleep 5 + run_forge_make + fi + exit "$forge_status" - name: Verify macOS signatures and notarization - if: matrix.platform == 'darwin' + if: matrix.platform == 'darwin' && needs.resolve.outputs.publish == 'true' shell: bash run: | set -euo pipefail @@ -303,7 +273,7 @@ jobs: done - name: Verify Windows Authenticode signature - if: matrix.platform == 'win32' + if: matrix.platform == 'win32' && needs.resolve.outputs.publish == 'true' shell: pwsh run: | $ErrorActionPreference = "Stop" @@ -324,26 +294,40 @@ jobs: RELEASE_TAG: ${{ needs.resolve.outputs.tag }} RELEASE_VERSION: ${{ needs.resolve.outputs.version }} RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }} - RELEASE_REPOSITORY: ${{ github.repository }} + RELEASE_REPOSITORY: ${{ needs.resolve.outputs.publish == 'true' && github.repository || '' }} run: | set -euo pipefail - signing_flag=() - if [[ "${{ matrix.signing }}" == required ]]; then signing_flag+=(--signed); fi - node scripts/stage-desktop-release.mjs stage \ - --forge-dir apps/desktop/out \ - --out-dir "desktop-staged/${{ matrix.target }}" \ - --target "${{ matrix.target }}" \ - --version "$RELEASE_VERSION" \ - --channel "$RELEASE_CHANNEL" \ - --tag "$RELEASE_TAG" \ - --repository "$RELEASE_REPOSITORY" \ - "${signing_flag[@]}" + stage_args=( + --forge-dir apps/desktop/out + --out-dir "desktop-staged/${{ matrix.target }}" + --target "${{ matrix.target }}" + --version "$RELEASE_VERSION" + --channel "$RELEASE_CHANNEL" + --tag "$RELEASE_TAG" + ) + if [[ -n "$RELEASE_REPOSITORY" ]]; then + stage_args+=(--repository "$RELEASE_REPOSITORY") + fi + if [[ "${{ matrix.signing }}" == required ]]; then + if [[ "${{ needs.resolve.outputs.publish }}" == true ]]; then + stage_args+=(--signed) + else + stage_args+=(--preview) + fi + fi + node scripts/stage-desktop-release.mjs stage "${stage_args[@]}" + staged_dir="desktop-staged/${{ matrix.target }}" + if [[ -z "$(find "$staged_dir" -maxdepth 1 -type f -print -quit)" ]]; then + echo "::error::desktop release staging produced no files under $staged_dir" + exit 1 + fi + find "$staged_dir" -maxdepth 1 -type f -print | sort - name: Upload staged desktop assets uses: actions/upload-artifact@v4 with: name: desktop-release-${{ matrix.target }} - path: desktop-staged/${{ matrix.target }}/* + path: desktop-staged/${{ matrix.target }} if-no-files-found: error retention-days: 14 @@ -352,6 +336,9 @@ jobs: needs: [resolve, build] if: needs.resolve.outputs.publish == 'true' && needs.build.result == 'success' runs-on: ubuntu-22.04 + concurrency: + group: desktop-release-publish-${{ needs.resolve.outputs.tag }} + cancel-in-progress: false steps: - name: Checkout source uses: actions/checkout@v4 @@ -387,10 +374,16 @@ jobs: --tag "$RELEASE_TAG" \ --repository "$RELEASE_REPOSITORY" \ --require-all-targets + if [[ -f github-release-assets/checksums.txt ]]; then + test ! -e github-release-assets/desktop-checksums.txt + mv github-release-assets/checksums.txt github-release-assets/desktop-checksums.txt + fi + test -f github-release-assets/desktop-checksums.txt cp desktop-staged/* github-release-assets/ find github-release-assets -maxdepth 1 -type f -print | sort - - name: Create draft GitHub Release + - name: Ensure GitHub Release exists + id: release env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.resolve.outputs.tag }} @@ -400,14 +393,19 @@ jobs: run: | set -euo pipefail if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --target "$SOURCE_REF" --draft=true + is_draft="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft')" + echo "created=false" >> "$GITHUB_OUTPUT" + echo "publish_after_upload=$is_draft" >> "$GITHUB_OUTPUT" + echo "Reusing existing GitHub Release $RELEASE_TAG" else gh release create "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ --target "$SOURCE_REF" \ - --title "Content Work OS Desktop $RELEASE_TAG" \ + --title "ContentCloud $RELEASE_TAG" \ --generate-notes \ --draft + echo "created=true" >> "$GITHUB_OUTPUT" + echo "publish_after_upload=true" >> "$GITHUB_OUTPUT" fi - name: Upload desktop release assets @@ -419,7 +417,8 @@ jobs: set -euo pipefail gh release upload "$RELEASE_TAG" github-release-assets/* --repo "$GITHUB_REPOSITORY" --clobber - - name: Publish release + - name: Publish draft release + if: steps.release.outputs.publish_after_upload == 'true' env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.resolve.outputs.tag }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 19c380f..d42bfa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ContentCloud 的重要变更记录在此文件中。 +## [0.29.0] - 2026-08-24 + +### Added + +- 增加按业务定制的视频、文章、电商和连载小说工作台,并通过 Workbench Registry 管理版本、摘要、模板绑定和租户启用。 +- 打通工作台 Action Contract 到 WorkTask、SOP、Gate、Runtime、Review、ApprovedSnapshot、Artifact、Delivery 和 Performance 的统一平台主链。 +- 增加 Claude Code 宿主 bootstrap、preflight、plan、apply、resume 与 Web 连接宿主选择能力。 +- 增加 Runtime 清理诊断、媒体合成、剪映导出和多业务完整链路验证能力。 + +### Changed + +- 更新分层平台架构、代码组织、插件边界、业务工作台 UI 原型、时序图和流程图文档。 +- 管理后台增加工作台、Runtime 恢复、清理和运营诊断视图;客户任务展示统一的流程、产物和效果摘要。 +- Server、Worker、Web、Desktop、CLI 和 npm 启动包统一升级到 `0.29.0`;视频生产场景插件继续固定在已验证的 `0.27.0`。 + +### Fixed + +- 修复客户连接客户端目录在开放 Claude Code 后仍只允许 Codex 的 HTTP 测试断言。 + ## [0.28.0] - 2026-08-17 ### Added diff --git a/VERSION b/VERSION index 697f087..ae6dd4e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.28.0 +0.29.0 diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index 3eb0968..8335cb4 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -9,6 +9,14 @@ import { MakerSquirrel } from "@electron-forge/maker-squirrel"; import { MakerZIP } from "@electron-forge/maker-zip"; const releaseSigningEnabled = process.env.CONTENTCLOUD_DESKTOP_SIGN === "1"; +const desktopProductName = "Content Work OS"; +const desktopExecutableName = "content-work-os"; +const desktopSquirrelName = "content_work_os"; +const linuxMakerOptions = { + name: desktopExecutableName, + productName: desktopProductName, + bin: desktopExecutableName, +}; function macSignOptions() { if (process.platform !== "darwin" || !releaseSigningEnabled) { @@ -49,8 +57,9 @@ function macNotarizeOptions() { } function squirrelOptions() { + const identity = { name: desktopSquirrelName }; if (process.platform !== "win32" || !releaseSigningEnabled) { - return {}; + return identity; } const certificateFile = @@ -63,21 +72,21 @@ function squirrelOptions() { ); } - return { certificateFile, certificatePassword }; + return { ...identity, certificateFile, certificatePassword }; } const config: ForgeConfig = { packagerConfig: { asar: true, - name: "Content Work OS", - executableName: "content-work-os", + name: desktopProductName, + executableName: desktopExecutableName, appBundleId: "run.zhongcao.contentcloud.desktop", - protocols: [{ name: "Content Work OS", schemes: ["contentcloud"] }], + protocols: [{ name: desktopProductName, schemes: ["contentcloud"] }], osxSign: macSignOptions(), osxNotarize: macNotarizeOptions(), win32metadata: { CompanyName: "ContentCloud", - ProductName: "Content Work OS", + ProductName: desktopProductName, FileDescription: "ContentCloud project workspace desktop", }, }, @@ -86,8 +95,8 @@ const config: ForgeConfig = { new MakerSquirrel(squirrelOptions()), new MakerZIP({}, ["darwin"]), new MakerDMG({}), - new MakerDeb({}), - new MakerRpm({}), + new MakerDeb({ options: linuxMakerOptions }), + new MakerRpm({ options: linuxMakerOptions }), ], plugins: [ new VitePlugin({ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 64c7526..10a75a5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,10 @@ { "name": "@limecloud/contentcloud-desktop", "private": true, - "version": "0.28.0", + "version": "0.29.0", + "description": "ContentCloud project workspace desktop", + "author": "ContentCloud", + "license": "Apache-2.0", "main": ".vite/build/main.js", "scripts": { "start": "electron-forge start", diff --git a/apps/web/package.json b/apps/web/package.json index 6c50bb0..5b65d1e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@limecloud/contentcloud-web", "private": true, - "version": "0.28.0", + "version": "0.29.0", "type": "module", "scripts": { "dev": "vite --config vite.config.ts --host 0.0.0.0", diff --git a/apps/web/src/admin/AdminShell.tsx b/apps/web/src/admin/AdminShell.tsx index c51a408..7318751 100644 --- a/apps/web/src/admin/AdminShell.tsx +++ b/apps/web/src/admin/AdminShell.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Activity, AlertTriangle, Boxes, CircleDollarSign, FolderKanban, Gauge, GitBranch, LayoutDashboard, LogOut, Menu, RefreshCw, Settings2, ShieldCheck, Users, Workflow, X, type LucideIcon, PlugZap } from 'lucide-react'; +import { Activity, AlertTriangle, Boxes, CircleDollarSign, FolderKanban, Gauge, GitBranch, LayoutDashboard, LogOut, Menu, RefreshCw, Settings2, ShieldCheck, Users, Workflow, X, type LucideIcon, PlugZap, Trash2 } from 'lucide-react'; import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { post } from '../api'; import { Banner, IconButton, Loading } from '../components/ui'; @@ -13,12 +13,14 @@ const routeTitles:Record={ [adminPath('products')]:'创作流程', [adminPath('releases')]:'发布版本', [adminPath('customers')]:'客户设置', + [adminPath('workbenches')]:'业务工作台', [adminPath('capabilities')]:'功能清单', [adminPath('skills')]:'自动化工具', [adminPath('executors')]:'连接的电脑', [adminPath('providers')]:'视频服务', [adminPath('jobs')]:'任务进度', [adminPath('alerts')]:'需要处理', + [adminPath('cleanup')]:'清理诊断', [adminPath('tenants')]:'客户列表', [adminPath('audit')]:'变更记录', [adminPath('costs')]:'任务统计' @@ -40,6 +42,7 @@ export function AdminShell() { setMobileOpen(false)}/> setMobileOpen(false)}/> setMobileOpen(false)}/> + {session.is_platform_admin&&setMobileOpen(false)}/>}
功能设置
setMobileOpen(false)}/> setMobileOpen(false)}/> @@ -48,6 +51,7 @@ export function AdminShell() {
任务跟进
setMobileOpen(false)}/> setMobileOpen(false)}/> + setMobileOpen(false)}/>
账号与记录
setMobileOpen(false)}/> setMobileOpen(false)}/> diff --git a/apps/web/src/admin/cleanupPage.test.tsx b/apps/web/src/admin/cleanupPage.test.tsx new file mode 100644 index 0000000..c9449f8 --- /dev/null +++ b/apps/web/src/admin/cleanupPage.test.tsx @@ -0,0 +1,18 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { AdminCleanupPage, cleanupStatusLabel } from './views/AdminCleanupPage'; + +describe('admin cleanup diagnostics', () => { + it('uses explicit labels for durable cleanup states', () => { + expect(cleanupStatusLabel('pending')).toBe('待清理'); + expect(cleanupStatusLabel('cleaned')).toBe('已清理'); + expect(cleanupStatusLabel('not_found')).toBe('对象已不存在'); + expect(cleanupStatusLabel('unknown')).toBe('unknown'); + }); + + it('renders the operator boundary before loading remote facts', () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain('清理诊断'); + expect(markup).toContain('不会创建任务、审批或产物事实'); + }); +}); diff --git a/apps/web/src/admin/context.tsx b/apps/web/src/admin/context.tsx index 5855f31..7bb3666 100644 --- a/apps/web/src/admin/context.tsx +++ b/apps/web/src/admin/context.tsx @@ -1,12 +1,13 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type PropsWithChildren } from 'react'; import { api, patch } from '../api'; -import type { AdminWorkOSView, ContentType, OperationsExecutorDirectory, OperationsSkillDirectory, PlatformOverview, PlatformTenant, Session, Tenant } from '../types'; +import type { AdminWorkOSView, ContentType, OperationsExecutorDirectory, OperationsSkillDirectory, PlatformOverview, PlatformTenant, Session, Tenant, WorkbenchRegistryView } from '../types'; import { normalizeAdminWorkOSView, normalizeOperationsExecutorDirectory, normalizeOperationsSkillDirectory } from './operationsData'; interface AdminContextValue { session:Session; data?:PlatformOverview; workOS?:AdminWorkOSView; + workbenchRegistry?:WorkbenchRegistryView; executorDirectory?:OperationsExecutorDirectory; skillDirectory?:OperationsSkillDirectory; executorDirectoryError:string; @@ -30,10 +31,11 @@ async function loadOptional(request:Promise):Promise> { } export async function loadAdminSnapshot(isPlatformAdmin:boolean) { - const [workOSResponse,executorResult,skillResult]=await Promise.all([ + const [workOSResponse,executorResult,skillResult,workbenchResult]=await Promise.all([ api('/api/bff/admin/work-os'), loadOptional(api('/api/bff/operations/executors')), - isPlatformAdmin?loadOptional(api('/api/bff/operations/skills')):Promise.resolve(undefined) + isPlatformAdmin?loadOptional(api('/api/bff/operations/skills')):Promise.resolve(undefined), + isPlatformAdmin?loadOptional(api('/api/bff/admin/workbenches')):Promise.resolve(undefined) ]); const workOS=normalizeAdminWorkOSView(workOSResponse); const executorDirectory=executorResult.ok @@ -44,12 +46,13 @@ export async function loadAdminSnapshot(isPlatformAdmin:boolean) { ?normalizeOperationsSkillDirectory(skillResult.value) :{configured:false,skills:[],generated_at:workOS.generated_at} :undefined; + const workbenchRegistry=isPlatformAdmin&&workbenchResult?.ok?workbenchResult.value:undefined; const data:PlatformOverview={counts:{tenants:1,active_tenants:workOS.environments.filter(item=>item.status==='active').length,users:0,projects:0,online_devices:executorDirectory.executors.filter(item=>item.presence_status==='online').length,active_runs:workOS.usage.running_count},tenants:[],users:[],generated_at:executorDirectory.generated_at||workOS.generated_at}; return { data, workOS, executorDirectory, - skillDirectory, + skillDirectory, workbenchRegistry, executorDirectoryError:executorResult.ok?'':executorResult.error, skillDirectoryError:skillResult&&!skillResult.ok?skillResult.error:'' }; @@ -60,6 +63,7 @@ export function AdminProvider({session,children}:PropsWithChildren<{session:Sess const [workOS,setWorkOS]=useState(); const [executorDirectory,setExecutorDirectory]=useState(); const [skillDirectory,setSkillDirectory]=useState(); + const [workbenchRegistry,setWorkbenchRegistry]=useState(); const [executorDirectoryError,setExecutorDirectoryError]=useState(''); const [skillDirectoryError,setSkillDirectoryError]=useState(''); const [loading,setLoading]=useState(true); @@ -72,6 +76,7 @@ export function AdminProvider({session,children}:PropsWithChildren<{session:Sess setWorkOS(snapshot.workOS); setExecutorDirectory(snapshot.executorDirectory); setSkillDirectory(snapshot.skillDirectory); + setWorkbenchRegistry(snapshot.workbenchRegistry); setExecutorDirectoryError(snapshot.executorDirectoryError); setSkillDirectoryError(snapshot.skillDirectoryError); setData(snapshot.data) @@ -90,7 +95,7 @@ export function AdminProvider({session,children}:PropsWithChildren<{session:Sess try{const tenant=await api(`/api/v1/admin/tenants/${tenantID}/content-capabilities/${contentType}`,{method:'PUT',body:JSON.stringify({enabled})});await refresh(true);return tenant} catch(value){setError(value instanceof Error?value.message:'内容能力更新失败');throw value} },[refresh]); - const value=useMemo(()=>({session,data,workOS,executorDirectory,skillDirectory,executorDirectoryError,skillDirectoryError,loading,refreshing,error,clearError:()=>setError(''),refresh,setTenantStatus,setTenantContentCapability}),[session,data,workOS,executorDirectory,skillDirectory,executorDirectoryError,skillDirectoryError,loading,refreshing,error,refresh,setTenantStatus,setTenantContentCapability]); + const value=useMemo(()=>({session,data,workOS,workbenchRegistry,executorDirectory,skillDirectory,executorDirectoryError,skillDirectoryError,loading,refreshing,error,clearError:()=>setError(''),refresh,setTenantStatus,setTenantContentCapability}),[session,data,workOS,workbenchRegistry,executorDirectory,skillDirectory,executorDirectoryError,skillDirectoryError,loading,refreshing,error,refresh,setTenantStatus,setTenantContentCapability]); return {children}; } diff --git a/apps/web/src/admin/operationsPages.test.tsx b/apps/web/src/admin/operationsPages.test.tsx index a1efe2b..2b61e16 100644 --- a/apps/web/src/admin/operationsPages.test.tsx +++ b/apps/web/src/admin/operationsPages.test.tsx @@ -1,14 +1,14 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { Route, Routes, StaticRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { AdminWorkOSView, OperationsExecutorDirectory, OperationsSkillDirectory, PlatformOverview, Session } from '../types'; +import type { AdminWorkOSView, OperationsExecutorDirectory, OperationsSkillDirectory, PlatformOverview, Session, WorkbenchRegistryView } from '../types'; const adminState=vi.hoisted(()=>({value:{} as Record})); vi.mock('./context',()=>({useAdmin:()=>adminState.value})); import { AdminShell } from './AdminShell'; -import { AdminCapabilityCatalogPage, AdminCapabilityDetailPage, AdminCustomerDetailPage, AdminCustomersPage, AdminExecutorDetailPage, AdminExecutorsPage, AdminOperationsOverview, AdminProductDetailPage, AdminProductReleasesPage, AdminProductsPage, AdminReleaseResultPage, AdminSkillDetailPage, AdminSkillsPage } from './views/AdminOperationsPages'; +import { AdminCapabilityCatalogPage, AdminCapabilityDetailPage, AdminCustomerDetailPage, AdminCustomersPage, AdminExecutorDetailPage, AdminExecutorsPage, AdminOperationsOverview, AdminProductDetailPage, AdminProductReleasesPage, AdminProductsPage, AdminReleaseResultPage, AdminSkillDetailPage, AdminSkillsPage, AdminWorkbenchRegistryPage, buildWorkbenchStatePayload, WorkbenchStateModal, workbenchLifecycleLabel } from './views/AdminOperationsPages'; const session:Session={user:{id:'user-1',email:'operator@example.com',display_name:'运营人员'},tenant:{id:'tenant-1',name:'平台运营',slug:'platform',status:'active',created_at:'2026-08-01T08:00:00Z'},role:'tenant_admin',is_platform_admin:true}; const overview:PlatformOverview={counts:{tenants:1,active_tenants:1,users:1,projects:1,online_devices:1,active_runs:1},tenants:[],users:[],generated_at:'2026-08-08T02:30:00Z'}; @@ -25,6 +25,7 @@ const workOS:AdminWorkOSView={ const executorDirectory:OperationsExecutorDirectory={executors:[{id:'executor-1',tenant_id:'tenant-1',display_name:'分镜工作站',executor_type:'contentcloud_device',status:'online',status_reason:'instance_connected',presence_status:'online',presence_reason:'instance_connected',environment_status:'repair_required',environment_reason:'plugin_drift',runtime_status:'throttled',runtime_reason:'capacity_limit',daemon_instance_id:'instance-1',connection_epoch:3,active_attempt_ids:['attempt-1'],runtimes:[{kind:'codex',version:'codex 1.2.3',status:'healthy',selected:true,capabilities:{events:true,resume:true,mcp_stdio:true,structured_output:true,max_parallel_sessions:8}},{kind:'claude',status:'unhealthy',error_code:'CLAUDE_AUTH_REQUIRED',selected:false,capabilities:{}}],workspaces:[{workspace_id:'workspace-1',project_id:'project-1',status:'repair_required',reason:'skill_drift',generation:'sha256:generation',plugin_receipt_digest:'sha256:plugin-receipt',observed_at:'2026-08-08T02:29:00Z'}],hostname:'storyboard.local',platform:'darwin',arch:'arm64',version:'0.21.0',capabilities:[{id:'inspiration_collection',version:'1.0.0',kind:'business_capability',input_schema:'contentcloud.inspiration-query/1.0',output_schema:'contentcloud.inspiration-result/1.0',presentation_profiles:['candidate-list'],local_only:true,digest:'capability-digest'}],projects:[{id:'project-1',brand_name:'果木食品',product_name:'品牌短片',status:'active'}],last_seen_at:'2026-08-08T02:29:00Z'}],generated_at:'2026-08-08T02:30:00Z',online_window_seconds:45}; const skillDirectory:OperationsSkillDirectory={configured:true,source:'verified_plugin_registry',registry_schema_version:'1.0',generated_at:'2026-08-08T02:30:00Z',skills:[{id:'contentcloud-script-writing',version:'1.2.0',digest:'sha256:skill',kind:'skill_pack',lifecycle:'published',available_for_new_runs:true,source:{repository:'https://github.com/limecloud/contentcloud',ref:'v1.2.0',license:'Apache-2.0'},signature:{status:'verified',algorithm:'ed25519',key_id:'plugin-release'},compatible_profiles:['contentcloud.video-production'],permissions:['workspace:read'],data_flow:{local_by_default:true,cloud_actions:[]},cost:{model:'included',notice:'Included in subscription.'},output_schemas:['contracts/content-item-3.0.schema.json'],evaluation:{status:'passed',report:'.agents/plugins/evaluations/script.json',digest:'sha256:evaluation',evidence:['contract-tests']},revocation:{status:'active'}}]}; +const workbenchRegistry:WorkbenchRegistryView={generated_at:'2026-08-08T02:30:00Z',entries:[{digest:'sha256:workbench',status:'published',template_aliases:['article_content'],tenant_ids:[],manifest:{'$schema':'https://contentcloud.run/schemas/workbench-plugin/1.0.0/workbench-plugin.schema.json',id:'contentcloud-workbench-article',version:'1.0.0',name:'文章创作工作台',content_types:['article'],experience:{template_id:'article_content'},ui:{renderer:'approved',layout:'article-editor',density:'comfortable',theme:'editorial-green',navigation:[{id:'overview',label:'文章首页',icon:'pen-line'}],stages:[{id:'draft',label:'文章草稿',outcome:'完成文章草稿',primary_action:'save_draft'}]}}}]}; function setAdminView(nextWorkOS:AdminWorkOSView=workOS,nextExecutors:OperationsExecutorDirectory=executorDirectory,nextSkills:OperationsSkillDirectory=skillDirectory){ adminState.value={session,data:overview,workOS:nextWorkOS,executorDirectory:nextExecutors,skillDirectory:nextSkills,executorDirectoryError:'',skillDirectoryError:'',loading:false,refreshing:false,error:'',clearError:()=>{},refresh:async()=>{},setTenantStatus:async()=>session.tenant,setTenantContentCapability:async()=>{throw new Error('not used')}}; @@ -77,6 +78,42 @@ describe('operations control plane pages',()=>{ expect(skillMarkup).toContain('可用于新任务'); }); + it('shows workbench registration only to platform administrators',()=>{ + setAdminView(); + adminState.value.workbenchRegistry=workbenchRegistry; + const platformMarkup=render(,'/admin/workbenches'); + expect(platformMarkup).toContain('登记版本'); + expect(platformMarkup).toContain('文章创作工作台'); + + adminState.value={...adminState.value,session:{...session,is_platform_admin:false},workbenchRegistry:undefined}; + const tenantMarkup=render(,'/admin/workbenches'); + expect(tenantMarkup).toContain('业务工作台仅限平台管理员'); + expect(tenantMarkup).not.toContain('登记版本'); + }); + + it('keeps workbench lifecycle actions explicit and refuses unsafe payloads',()=>{ + expect(workbenchLifecycleLabel('draft')).toBe('发布'); + expect(workbenchLifecycleLabel('published')).toBe('正常退役'); + expect(workbenchLifecycleLabel('retired')).toBe('恢复发布'); + expect(workbenchLifecycleLabel('revoked')).toBe('已安全撤销'); + expect(()=>buildWorkbenchStatePayload(workbenchRegistry.entries[0],'revoked','tenant-a','')).toThrow('必须填写原因'); + expect(()=>buildWorkbenchStatePayload({...workbenchRegistry.entries[0],status:'revoked'},'published','tenant-a')).toThrow('不能恢复'); + expect(()=>buildWorkbenchStatePayload(workbenchRegistry.entries[0],'draft','tenant-a')).toThrow('不能退回草稿'); + expect(buildWorkbenchStatePayload(workbenchRegistry.entries[0],'published','tenant-a\ntenant-a, tenant-b',' 来源校验失败 ')).toEqual({status:'published',tenant_ids:['tenant-a','tenant-b'],reason:'来源校验失败'}); + }); + + it('renders the separate scope, retirement, and security-revocation controls',()=>{ + const publishedMarkup=render({}} reason="" setReason={()=>{}} busy={false} notice="" onClose={()=>{}} onUpdate={()=>{}}/>); + expect(publishedMarkup).toContain('保存租户范围'); + expect(publishedMarkup).toContain('正常退役'); + expect(publishedMarkup).toContain('永久安全撤销'); + expect(publishedMarkup).toContain('撤销后不可恢复'); + const revokedMarkup=render({}} reason="" setReason={()=>{}} busy={false} notice="" onClose={()=>{}} onUpdate={()=>{}}/>); + expect(revokedMarkup).toContain('不能恢复、发布或调整租户范围'); + expect(revokedMarkup).toContain('签名验证失败'); + expect(revokedMarkup).not.toContain('安全撤销原因'); + }); + it('keeps an unconfigured skill registry explicit and empty',()=>{ setAdminView(workOS,executorDirectory,{configured:false,skills:[],generated_at:'2026-08-08T02:30:00Z'}); const markup=render(,'/admin/skills'); diff --git a/apps/web/src/admin/routes.test.ts b/apps/web/src/admin/routes.test.ts index cf012ff..d3d39fb 100644 --- a/apps/web/src/admin/routes.test.ts +++ b/apps/web/src/admin/routes.test.ts @@ -8,8 +8,10 @@ describe('admin routes',()=>{ it('maps every admin section to a stable deep link',()=>{ expect(adminPath('dashboard')).toBe('/admin/dashboard'); expect(adminPath('products')).toBe('/admin/products'); + expect(adminPath('workbenches')).toBe('/admin/workbenches'); expect(adminPath('capabilities')).toBe('/admin/capabilities'); expect(adminPath('jobs')).toBe('/admin/jobs'); + expect(adminPath('cleanup')).toBe('/admin/cleanup'); expect(adminPath('providers')).toBe('/admin/providers'); expect(adminPath('tenants')).toBe('/admin/tenants'); expect(adminPath('audit')).toBe('/admin/audit'); @@ -33,7 +35,7 @@ describe('admin routes',()=>{ }); it('mounts the new operations workspaces as independent pages',()=>{ - const paths=['products','releases','customers','capabilities','skills','executors','providers','jobs','alerts','tenants','audit','costs']; + const paths=['products','releases','customers','workbenches','capabilities','skills','executors','providers','jobs','alerts','cleanup','tenants','audit','costs']; for(const path of paths){ const matches=matchRoutes(appRoutes,`/admin/${path}`); expect(matches?.map(item=>item.route.path)).toEqual(['/admin',undefined,path]); diff --git a/apps/web/src/admin/routes.ts b/apps/web/src/admin/routes.ts index 8999ab3..506da78 100644 --- a/apps/web/src/admin/routes.ts +++ b/apps/web/src/admin/routes.ts @@ -1,16 +1,18 @@ -export type AdminRoute = 'dashboard'|'products'|'releases'|'customers'|'capabilities'|'skills'|'executors'|'providers'|'jobs'|'alerts'|'tenants'|'audit'|'costs'; +export type AdminRoute = 'dashboard'|'products'|'releases'|'customers'|'workbenches'|'capabilities'|'skills'|'executors'|'providers'|'jobs'|'alerts'|'cleanup'|'tenants'|'audit'|'costs'; const adminPaths: Record = { dashboard: '/admin/dashboard', products: '/admin/products', releases: '/admin/releases', customers: '/admin/customers', + workbenches: '/admin/workbenches', capabilities: '/admin/capabilities', skills: '/admin/skills', executors: '/admin/executors', providers: '/admin/providers', jobs: '/admin/jobs', alerts: '/admin/alerts', + cleanup: '/admin/cleanup', tenants: '/admin/tenants', audit: '/admin/audit', costs: '/admin/costs' diff --git a/apps/web/src/admin/runtimePage.test.tsx b/apps/web/src/admin/runtimePage.test.tsx index a2615ea..d760920 100644 --- a/apps/web/src/admin/runtimePage.test.tsx +++ b/apps/web/src/admin/runtimePage.test.tsx @@ -7,10 +7,10 @@ const detail: RuntimeJobDetail = { summary: { id: 'job-forked-1', work_task_id: 'task-1', task_title: '恢复任务', customer_name: '果木食品客户', project_id: 'project-1', project_name: '果木食品', product_name: '品牌短视频', product_version: 2, current_step_name: '生成分镜', completed_steps: 1, total_steps: 3, task_status: 'running', task_next_action: '继续处理', state: 'running', status_since: '2026-08-08T02:00:00Z', blocking_reason: '任务正在按计划处理', recommended_action: '继续观察当前任务', cost: { status: 'not_recorded', amount_minor: 0, effect_count: 0 }, plan_digest: 'sha256:plan', binding_digest: 'sha256:binding', input_digest: 'sha256:input', runtime_policy_id: 'runtime-policy/customer-studio-v1', contract_major: 1, contract_minor: 0, root_job_run_id: 'job-source-1', source_job_run_id: 'job-source-1', checkpoint_id: 'checkpoint-1', priority: 1, allowed_actions: ['replay', 'refresh', 'cancel'], node_count: 1, node_states: { running: 1 }, effect_count: 1, checkpoint_count: 1, created_at: '2026-08-08T02:00:00Z', updated_at: '2026-08-08T02:01:00Z' }, - plan: { id: 'plan-1', sop_id: 'sop-1', sop_version: 1, sop_digest: 'sha256:sop', schema_version: 'contentcloud.job-plan/1.0', digest: 'sha256:plan', customer_steps: [], compiled_at: '2026-08-08T02:00:00Z' }, + plan: { id: 'plan-1', graph_version: 1, sop_id: 'sop-1', sop_version: 1, sop_digest: 'sha256:sop', schema_version: 'contentcloud.job-plan/1.0', digest: 'sha256:plan', nodes: [], edges: [], customer_steps: [], limits: { max_nodes: 100, max_depth: 32, max_dynamic_descendants: 100, max_concurrent_nodes: 20, max_attempts_per_node: 3, max_cost_minor: 0 }, compiled_at: '2026-08-08T02:00:00Z' }, nodes: [], attempts: [], events: [], agents: [], gates: [], state_collections: [], effects: [{ id: 'effect-1', node_run_id: 'node-1', kind: 'media.generate', state: 'unknown', request_digest: 'sha256:req', cost_minor: 0, currency: 'CNY', safe_summary: {}, version: 2, allowed_actions: ['reconcile'], created_at: '2026-08-08T02:00:00Z', updated_at: '2026-08-08T02:00:30Z' }], - checkpoints: [{ id: 'checkpoint-1', node_key: 'brief', plan_digest: 'sha256:plan', state_ref_count: 1, output_ref_count: 1, completed_nodes: ['brief'], digest: 'sha256:checkpoint', allowed_actions: [], blocked_reason: '先暂停或结束源执行实例', created_at: '2026-08-08T02:00:00Z' }], + checkpoints: [{ id: 'checkpoint-1', node_key: 'brief', plan_digest: 'sha256:plan', state_ref_count: 1, output_ref_count: 1, completed_nodes: ['brief'], digest: 'sha256:checkpoint', allowed_actions: [], blocked_reason: '先暂停或结束源执行实例', created_at: '2026-08-08T02:00:00Z' }], fanout_sets: [], generated_at: '2026-08-08T02:01:00Z' }; diff --git a/apps/web/src/admin/views/AdminCleanupPage.tsx b/apps/web/src/admin/views/AdminCleanupPage.tsx new file mode 100644 index 0000000..87dae80 --- /dev/null +++ b/apps/web/src/admin/views/AdminCleanupPage.tsx @@ -0,0 +1,86 @@ +import { AlertTriangle, CheckCircle2, Clock3, RefreshCw, Trash2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { api, post } from '../../api'; +import type { RuntimeCleanupDiagnostic, RuntimeCleanupStatus } from '../../types'; + +const statusOptions: Array<{ value: '' | RuntimeCleanupStatus; label: string }> = [ + { value: '', label: '全部状态' }, + { value: 'pending', label: '待清理' }, + { value: 'retrying', label: '清理中' }, + { value: 'failed', label: '清理失败' }, + { value: 'cleaned', label: '已清理' }, + { value: 'not_found', label: '对象已不存在' }, +]; + +export function cleanupStatusLabel(value: string): string { + return statusOptions.find(item => item.value === value)?.label || value || '未知状态'; +} + +function statusTone(value: string): string { + if (value === 'cleaned' || value === 'not_found') return 'success'; + if (value === 'failed') return 'danger'; + if (value === 'pending' || value === 'retrying') return 'warning'; + return 'neutral'; +} + +function dateTime(value?: string): string { + if (!value) return '未安排'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '未安排'; + return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(date); +} + +function short(value: string, length = 24): string { + return value.length > length ? `${value.slice(0, length - 1)}…` : value; +} + +export function AdminCleanupPage() { + const [status, setStatus] = useState<'' | RuntimeCleanupStatus>(''); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(''); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + + const load = async () => { + setLoading(true); + setError(''); + try { + const query = status ? `?status=${encodeURIComponent(status)}&limit=100` : '?limit=100'; + const next = await api(`/api/bff/runtime/cleanup-diagnostics${query}`); + setItems(next || []); + } catch (value) { + setError(value instanceof Error ? value.message : '清理诊断读取失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { void load(); }, [status]); + + const retry = async (item: RuntimeCleanupDiagnostic) => { + if (!window.confirm(`确认重试清理临时对象“${item.object_key}”?这不会重新提交业务任务或生成新的产物。`)) return; + setBusy(item.id); + setError(''); + setNotice(''); + try { + const result = await post<{ diagnostic: RuntimeCleanupDiagnostic; delete_attempted: boolean }>(`/api/bff/runtime/cleanup-diagnostics/${encodeURIComponent(item.id)}/retry`); + setItems(current => current.map(value => value.id === item.id ? result.diagnostic : value)); + setNotice(result.diagnostic.status === 'cleaned' || result.diagnostic.status === 'not_found' ? '清理诊断已收敛,业务事实保持不变。' : '清理仍未完成,系统已记录下一次重试时间。'); + } catch (value) { + setError(value instanceof Error ? value.message : '清理重试失败'); + } finally { + setBusy(''); + } + }; + + return
+
运行治理 / 临时对象

清理诊断

处理数据库事实已经回滚、但临时 Blob 仍需清理的异常。这里不会创建任务、审批或产物事实。

+
Runtime 清理事实状态、版本和重试次数由服务端持久化并通过 CAS 更新;清理成功不会改变 ApprovedSnapshot、Artifact 或 Delivery。
待处理{items.filter(item => item.status === 'pending' || item.status === 'failed').length}清理中{items.filter(item => item.status === 'retrying').length}已收敛{items.filter(item => item.status === 'cleaned' || item.status === 'not_found').length}
+ {error &&
{error}
} + {notice &&
{notice}
} +
持久化诊断

{items.length} 条记录

+ {loading ?
正在读取清理诊断…
: items.length === 0 ?
没有匹配的清理诊断数据库事实和临时对象保持一致。
:
临时对象失败原因状态尝试下次重试更新时间操作
{items.map(item =>
{short(item.object_key)}{item.task_id} · {item.manifest_digest ? short(item.manifest_digest, 18) : '无摘要'}{item.cause_code}{item.cause_summary}{item.cleanup_error && {item.cleanup_error}}{cleanupStatusLabel(item.status)}{item.attempt_count}{dateTime(item.next_retry_at)}{dateTime(item.updated_at)}{(item.status === 'pending' || item.status === 'failed') ? : 无需处理}
)}
} +
+
; +} diff --git a/apps/web/src/admin/views/AdminOperationsPages.tsx b/apps/web/src/admin/views/AdminOperationsPages.tsx index 60cf61d..22d5bdb 100644 --- a/apps/web/src/admin/views/AdminOperationsPages.tsx +++ b/apps/web/src/admin/views/AdminOperationsPages.tsx @@ -1,12 +1,13 @@ -import { Activity, AlertTriangle, ArrowLeft, ArrowRight, Ban, CircleDashed, ClipboardCheck, Copy, FileCheck2, FolderKanban, GitBranch, KeyRound, PackageCheck, RefreshCw, ShieldCheck, Sparkles, Users, Wrench } from 'lucide-react'; +import { Activity, AlertTriangle, ArrowLeft, ArrowRight, Ban, CircleDashed, ClipboardCheck, Copy, FileCheck2, FolderKanban, GitBranch, KeyRound, PackageCheck, Plus, RefreshCw, Save, Settings2, ShieldAlert, ShieldCheck, Sparkles, Users, Wrench } from 'lucide-react'; import { useState, type ReactNode } from 'react'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { post } from '../../api'; -import { Button, Empty, Status } from '../../components/ui'; +import { patch, post } from '../../api'; +import { Button, Empty, Field, Modal, Status } from '../../components/ui'; import { AdminEnvironmentPanel, AdminSOPPanel, CreateProductModal, createAdminProduct, emptyAdminProductDraft } from '../WorkOSConfigPanels'; import { useAdmin } from '../context'; import { adminCapabilityPath, adminCustomerPath, adminCustomersForProductPath, adminExecutorPath, adminPath, adminProductPath, adminProductVersionPath, adminReleaseResultPath, adminSkillPath } from '../routes'; import { auditActionLabel, auditSubjectLabel } from '../../uiLabels'; +import type { WorkbenchPluginEntry, WorkbenchPluginManifest } from '../../types'; const dateTime = (value:string) => new Intl.DateTimeFormat('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}).format(new Date(value)); @@ -39,6 +40,81 @@ export function AdminOperationsOverview() { ; } +export function AdminWorkbenchRegistryPage() { + const {session,workbenchRegistry,refresh}=useAdmin(); + const [busy,setBusy]=useState(''); + const [createOpen,setCreateOpen]=useState(false); + const [manageEntry,setManageEntry]=useState(); + const [manageScope,setManageScope]=useState(''); + const [revokeReason,setRevokeReason]=useState(''); + const [manageNotice,setManageNotice]=useState(''); + const [createNotice,setCreateNotice]=useState(''); + const [pageNotice,setPageNotice]=useState(''); + const [manifestJSON,setManifestJSON]=useState(defaultWorkbenchManifestJSON); + const [templateAliases,setTemplateAliases]=useState(''); + const [tenantIDs,setTenantIDs]=useState(''); + if(!session.is_platform_admin)return ; + if(!workbenchRegistry)return ; + const update=async(entry:WorkbenchPluginEntry,target:string,scopeText:string,reason='')=>{ + const key=`${entry.manifest.id}@${entry.manifest.version}`; + setBusy(key);setManageNotice('');setPageNotice(''); + try{ + const payload=buildWorkbenchStatePayload(entry,target,scopeText,reason); + await patch(`/api/bff/admin/workbenches/${encodeURIComponent(entry.manifest.id)}/versions/${encodeURIComponent(entry.manifest.version)}`,payload); + await refresh(true);setManageEntry(undefined);setRevokeReason(''); + }catch(value){setManageNotice(value instanceof Error?value.message:'工作台版本状态更新失败')} + finally{setBusy('')} + }; + const register=async()=>{ + setBusy('create');setCreateNotice(''); + try{ + const manifest=JSON.parse(manifestJSON) as WorkbenchPluginManifest; + await post('/api/bff/admin/workbenches',{manifest,template_aliases:splitWorkbenchList(templateAliases),tenant_ids:splitWorkbenchList(tenantIDs)}); + await refresh(true); + setCreateOpen(false); + setManifestJSON(defaultWorkbenchManifestJSON);setTemplateAliases('');setTenantIDs(''); + }catch(value){setCreateNotice(value instanceof Error?value.message:'工作台 manifest 不是有效 JSON 或登记失败')} + finally{setBusy('')} + }; + return
}/>
平台 Registry 已连接共 {workbenchRegistry.entries.length} 个声明版本。发布后客户工作台会按租户范围读取固定版本。
已发布{workbenchRegistry.entries.filter(item=>item.status==='published').length}草稿{workbenchRegistry.entries.filter(item=>item.status==='draft').length}已停用或撤销{workbenchRegistry.entries.filter(item=>['retired','revoked'].includes(item.status)).length}
{pageNotice&&
{pageNotice}
}
清单由服务端持久化 Registry 下发}/>
工作台版本状态业务类型租户范围管理
{workbenchRegistry.entries.map(entry=>{const key=entry.manifest.id+'@'+entry.manifest.version;return
{entry.manifest.name}{entry.manifest.id} · {entry.manifest.ui.layout} · {entry.manifest.ui.density} · {entry.manifest.ui.theme}v{entry.manifest.version}{entry.manifest.content_types.join('、')}{entry.tenant_ids.length?`${entry.tenant_ids.length} 个租户`:'全部租户'}
})}
{createOpen&&{setCreateOpen(false);setCreateNotice('')}} onCreate={register}/>} {manageEntry&&setManageEntry(undefined)} onUpdate={(target,reason)=>void update(manageEntry,target,manageScope,reason)}/>}
; +} + +const defaultWorkbenchManifestJSON=JSON.stringify({ + $schema:'https://contentcloud.run/schemas/workbench-plugin/1.0.0/workbench-plugin.schema.json', + id:'custom-content-workbench',version:'1.0.0',name:'自定义内容工作台',content_types:['article'], + experience:{template_id:'article_content'}, + ui:{renderer:'approved',layout:'article-editor',density:'comfortable',theme:'editorial-green',navigation:[ + {id:'overview',label:'工作台首页',icon:'pen-line'},{id:'tasks',label:'任务',icon:'list-checks'},{id:'deliveries',label:'交付',icon:'file-check'} + ],stages:[ + {id:'brief',label:'任务简报',outcome:'固定任务目标',primary_action:'save_brief'}, + {id:'draft',label:'内容草稿',outcome:'完成内容草稿',primary_action:'save_draft'}, + {id:'delivery',label:'交付',outcome:'生成可追溯交付包',primary_action:'create_delivery'} + ]} +},null,2); + +export function splitWorkbenchList(value:string):string[]{return Array.from(new Set(value.split(/[\n,,]+/).map(item=>item.trim()).filter(Boolean)))} + +export function buildWorkbenchStatePayload(entry:WorkbenchPluginEntry,target:string,scopeText:string,reason=''):{status:string;tenant_ids:string[];reason?:string}{ + const tenant_ids=splitWorkbenchList(scopeText); + if(entry.status==='revoked'&&target!=='revoked')throw new Error('已安全撤销的版本不能恢复或修改'); + if(target==='draft')throw new Error('已发布或已停用的版本不能退回草稿'); + if(target==='revoked'&&!reason.trim())throw new Error('永久安全撤销必须填写原因'); + return {status:target,tenant_ids,...(reason.trim()?{reason:reason.trim()}:{})}; +} + +export function workbenchLifecycleLabel(status:string):string{ + return status==='draft'?'发布':status==='published'?'正常退役':status==='retired'?'恢复发布':status==='revoked'?'已安全撤销':'查看'; +} + +export function WorkbenchStateModal({entry,scope,setScope,reason,setReason,busy,notice,onClose,onUpdate}:{entry:WorkbenchPluginEntry;scope:string;setScope:(value:string)=>void;reason:string;setReason:(value:string)=>void;busy:boolean;notice:string;onClose:()=>void;onUpdate:(target:string,reason?:string)=>void}){ + const lifecycleTarget=entry.status==='draft'?'published':entry.status==='published'?'retired':entry.status==='retired'?'published':''; + return

这里仅修改客户工作面的租户范围和生命周期。流程、审批、Runtime、产物、交付与效果数据仍由平台底层事实源拥有。