mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-15 09:11:18 +00:00
Merge b8d8d497c9 into 9a25dcfaef
This commit is contained in:
commit
e30fc43004
23 changed files with 1548 additions and 71 deletions
|
|
@ -1,6 +1,12 @@
|
|||
# 草稿与结构校验 API
|
||||
|
||||
这是创作平台的第一层:创建私有草稿、维护文件和运行结构校验。尚不包含网页工作台、模型执行、MCP 探测、自动修复确认或发布集成。草稿校验通过不等于技能已发布,也不代表运行效果或安全扫描通过。
|
||||
这是创作平台的第一层:创建私有草稿、维护文件和运行结构校验。网页工作台位于 `/dashboard/authoring`:创建、删除草稿,编辑文本或上传二进制资源,发起结构校验并查看运行状态与问题。尚不包含模型执行、MCP 探测、自动修复确认或发布集成。草稿校验通过不等于技能已发布,也不代表运行效果或安全扫描通过。
|
||||
|
||||
## 网页工作台
|
||||
|
||||
登录后从个人中心的“技能创作”进入。文件保存携带读取时的草稿 revision,遇到并发冲突保留本地编辑;重新加载或切换文件前确认放弃未保存修改。创建同名文件不会用空内容覆盖已有资源。文本是否可编辑以读取接口的 `encoding` 为准,二进制资源只显示元数据并通过上传替换。
|
||||
|
||||
未保存修改时不能发起校验。运行状态和 findings 通过轮询更新;活动运行支持取消,终态后停止轮询并刷新草稿校验状态。问题面板显示规则、消息及文件位置,作者可手动编辑后重新校验。结构检查通过不等于脚本或模型行为已验证。
|
||||
|
||||
## 权限与文件
|
||||
|
||||
|
|
|
|||
|
|
@ -1,62 +1,64 @@
|
|||
{
|
||||
"schema": 1,
|
||||
"description": "Source declarations, not runtime DOM counts, pageviews, successful outcomes or a closed runtime enum. Forwarding sites require explicit callers. See frontend-analytics.md for gaps and event policy.",
|
||||
"counts": {"routes":54,"uniqueIds":804,"literalJsxSites":821,"literalJsxValues":975,"navigationDefinitions":29,"forwardingSites":36},
|
||||
"counts": {"routes":56,"uniqueIds":805,"literalJsxSites":821,"literalJsxValues":975,"navigationDefinitions":30,"forwardingSites":36},
|
||||
"routes": [
|
||||
{"name":"landingRoute","path":"/","declaration":{"file":"web/src/app/router.tsx","line":258},"implementation":{"file":"web/src/pages/landing.tsx","line":257,"export":"LandingPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"skillsRoute","path":"skills","declaration":{"file":"web/src/app/router.tsx","line":264},"implementation":{"file":"web/src/pages/home.tsx","line":12,"export":"HomePage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"loginRoute","path":"login","declaration":{"file":"web/src/app/router.tsx","line":270},"implementation":{"file":"web/src/pages/login.tsx","line":22,"export":"LoginPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"registerRoute","path":"register","declaration":{"file":"web/src/app/router.tsx","line":281},"implementation":{"file":"web/src/pages/register.tsx","line":55,"export":"RegisterPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"resetPasswordRoute","path":"reset-password","declaration":{"file":"web/src/app/router.tsx","line":290},"implementation":{"file":"web/src/pages/reset-password.tsx","line":13,"export":"ResetPasswordPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"privacyRoute","path":"privacy","declaration":{"file":"web/src/app/router.tsx","line":296},"implementation":{"file":"web/src/pages/privacy.tsx","line":155,"export":"PrivacyPolicyPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"searchRoute","path":"search","declaration":{"file":"web/src/app/router.tsx","line":302},"implementation":{"file":"web/src/pages/search.tsx","line":88,"export":"SearchPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"githubSkillsRoute","path":"github","declaration":{"file":"web/src/app/router.tsx","line":318},"implementation":{"file":"web/src/pages/github-skills.tsx","line":39,"export":"GithubSkillsPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"suitesRoute","path":"suites","declaration":{"file":"web/src/app/router.tsx","line":329},"implementation":{"file":"web/src/pages/suites.tsx","line":17,"export":"SuitesPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"suiteDetailRoute","path":"/suite/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":335},"implementation":{"file":"web/src/pages/suite-detail.tsx","line":22,"export":"SuiteDetailPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"termsRoute","path":"terms","declaration":{"file":"web/src/app/router.tsx","line":344},"implementation":{"file":"web/src/pages/terms.tsx","line":171,"export":"TermsOfServicePage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"namespaceRoute","path":"/space/$namespace","declaration":{"file":"web/src/app/router.tsx","line":350},"implementation":{"file":"web/src/pages/namespace.tsx","line":5,"export":"NamespacePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"joinRoute","path":"join","declaration":{"file":"web/src/app/router.tsx","line":357},"implementation":{"file":"web/src/pages/join.tsx","line":12,"export":"JoinPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"skillDetailRoute","path":"/space/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":364},"implementation":{"file":"web/src/pages/skill-detail.tsx","line":133,"export":"SkillDetailPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"skillVersionCompareRoute","path":"/space/$namespace/$slug/compare","declaration":{"file":"web/src/app/router.tsx","line":374},"implementation":{"file":"web/src/pages/skill-version-compare.tsx","line":22,"export":"SkillVersionComparePage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"dashboardRoute","path":"dashboard","declaration":{"file":"web/src/app/router.tsx","line":384},"implementation":{"file":"web/src/pages/dashboard.tsx","line":98,"export":"DashboardPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSkillsRoute","path":"dashboard/skills","declaration":{"file":"web/src/app/router.tsx","line":391},"implementation":{"file":"web/src/pages/dashboard/my-skills.tsx","line":46,"export":"MySkillsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardPublishRoute","path":"dashboard/publish","declaration":{"file":"web/src/app/router.tsx","line":404},"implementation":{"file":"web/src/pages/dashboard/publish.tsx","line":41,"export":"PublishPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteCreateRoute","path":"dashboard/suites/new","declaration":{"file":"web/src/app/router.tsx","line":430},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":716,"export":"SuiteCreatePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuitesRoute","path":"dashboard/suites","declaration":{"file":"web/src/app/router.tsx","line":437},"implementation":{"file":"web/src/pages/dashboard/my-suites.tsx","line":21,"export":"MySuitesPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteManagementRoute","path":"dashboard/suites/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":447},"implementation":{"file":"web/src/pages/dashboard/suite-management.tsx","line":31,"export":"SuiteManagementPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuitePublishingTaskRoute","path":"dashboard/suites/publishing/$operationId","declaration":{"file":"web/src/app/router.tsx","line":463},"implementation":{"file":"web/src/pages/dashboard/suite-publishing-task.tsx","line":6,"export":"SuitePublishingTaskPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteEditRoute","path":"dashboard/suites/$namespace/$slug/edit","declaration":{"file":"web/src/app/router.tsx","line":481},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":720,"export":"SuiteEditPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteVersionCreateRoute","path":"dashboard/suites/$namespace/$slug/new-version","declaration":{"file":"web/src/app/router.tsx","line":491},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":726,"export":"SuiteVersionCreatePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespacesRoute","path":"dashboard/namespaces","declaration":{"file":"web/src/app/router.tsx","line":503},"implementation":{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":18,"export":"MyNamespacesPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceManagementRoute","path":"dashboard/namespaces/$slug","declaration":{"file":"web/src/app/router.tsx","line":510},"implementation":{"file":"web/src/pages/dashboard/namespace-management.tsx","line":7,"export":"NamespaceManagementPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceSettingsRoute","path":"dashboard/namespaces/$slug/settings","declaration":{"file":"web/src/app/router.tsx","line":517},"implementation":{"file":"web/src/pages/dashboard/namespace-settings.tsx","line":14,"export":"NamespaceSettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceMembersRoute","path":"dashboard/namespaces/$slug/members","declaration":{"file":"web/src/app/router.tsx","line":524},"implementation":{"file":"web/src/pages/dashboard/namespace-members.tsx","line":45,"export":"NamespaceMembersPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceReviewsRoute","path":"dashboard/namespaces/$slug/reviews","declaration":{"file":"web/src/app/router.tsx","line":531},"implementation":{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":145,"export":"NamespaceReviewsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceGithubSourcesRoute","path":"dashboard/namespaces/$slug/github-sources","declaration":{"file":"web/src/app/router.tsx","line":541},"implementation":{"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":26,"export":"NamespaceGithubSourcesPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardGovernanceRoute","path":"dashboard/governance","declaration":{"file":"web/src/app/router.tsx","line":548},"implementation":{"file":"web/src/pages/dashboard/governance.tsx","line":40,"export":"GovernancePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardGithubSyncRoute","path":"dashboard/github-sync","declaration":{"file":"web/src/app/router.tsx","line":555},"implementation":{"file":"web/src/pages/dashboard/github-sync.tsx","line":20,"export":"GithubSyncPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"dashboardGithubDeepDiscoveryRoute","path":"dashboard/github-deep-discovery","declaration":{"file":"web/src/app/router.tsx","line":588},"implementation":null,"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardReviewsRoute","path":"dashboard/reviews","declaration":{"file":"web/src/app/router.tsx","line":597},"implementation":{"file":"web/src/pages/dashboard/reviews.tsx","line":39,"export":"ReviewsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardReviewProgressRoute","path":"dashboard/review-progress","declaration":{"file":"web/src/app/router.tsx","line":607},"implementation":{"file":"web/src/pages/dashboard/review-progress.tsx","line":27,"export":"ReviewProgressPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardReportsRoute","path":"dashboard/reports","declaration":{"file":"web/src/app/router.tsx","line":629},"implementation":{"file":"web/src/pages/dashboard/reports.tsx","line":31,"export":"ReportsPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"dashboardReviewDetailRoute","path":"dashboard/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":636},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":530,"export":"ReviewDetailPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceReviewDetailRoute","path":"dashboard/namespaces/$slug/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":643},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":542,"export":"NamespaceReviewDetailPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardPromotionsRoute","path":"dashboard/promotions","declaration":{"file":"web/src/app/router.tsx","line":650},"implementation":{"file":"web/src/pages/dashboard/promotions.tsx","line":408,"export":"PromotionsPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"dashboardStarsRoute","path":"dashboard/stars","declaration":{"file":"web/src/app/router.tsx","line":657},"implementation":{"file":"web/src/pages/dashboard/stars.tsx","line":12,"export":"MyStarsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSubscriptionsRoute","path":"dashboard/subscriptions","declaration":{"file":"web/src/app/router.tsx","line":667},"implementation":{"file":"web/src/pages/dashboard/subscriptions.tsx","line":12,"export":"MySubscriptionsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNotificationsRoute","path":"dashboard/notifications","declaration":{"file":"web/src/app/router.tsx","line":677},"implementation":{"file":"web/src/pages/notifications.tsx","line":48,"export":"NotificationsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardTokensRoute","path":"dashboard/tokens","declaration":{"file":"web/src/app/router.tsx","line":684},"implementation":{"file":"web/src/pages/dashboard/tokens.tsx","line":8,"export":"TokensPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"cliAuthRoute","path":"cli/auth","declaration":{"file":"web/src/app/router.tsx","line":691},"implementation":{"file":"web/src/pages/cli-auth.tsx","line":48,"export":"CliAuthPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"deviceAuthRoute","path":"device","declaration":{"file":"web/src/app/router.tsx","line":706},"implementation":{"file":"web/src/pages/device.tsx","line":20,"export":"DeviceAuthPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"settingsSecurityRoute","path":"settings/security","declaration":{"file":"web/src/app/router.tsx","line":712},"implementation":{"file":"web/src/pages/settings/security.tsx","line":28,"export":"SecuritySettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"settingsProfileRoute","path":"settings/profile","declaration":{"file":"web/src/app/router.tsx","line":719},"implementation":{"file":"web/src/pages/settings/profile.tsx","line":40,"export":"ProfileSettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"settingsNotificationsRoute","path":"settings/notifications","declaration":{"file":"web/src/app/router.tsx","line":726},"implementation":{"file":"web/src/pages/settings/notification-settings.tsx","line":8,"export":"NotificationSettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"settingsAccountsRoute","path":"settings/accounts","declaration":{"file":"web/src/app/router.tsx","line":733},"implementation":null,"guarded":true,"roleProtected":false},
|
||||
{"name":"adminUsersRoute","path":"admin/users","declaration":{"file":"web/src/app/router.tsx","line":742},"implementation":{"file":"web/src/pages/admin/users.tsx","line":47,"export":"AdminUsersPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"adminAuditLogRoute","path":"admin/audit-log","declaration":{"file":"web/src/app/router.tsx","line":749},"implementation":{"file":"web/src/pages/admin/audit-log.tsx","line":50,"export":"AuditLogPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"adminLabelsRoute","path":"admin/labels","declaration":{"file":"web/src/app/router.tsx","line":756},"implementation":{"file":"web/src/pages/admin/labels.tsx","line":118,"export":"AdminLabelsPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"systemConfigRoute","path":"admin/system-config","declaration":{"file":"web/src/app/router.tsx","line":763},"implementation":{"file":"web/src/pages/admin/system-config.tsx","line":13,"export":"SystemConfigPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"adminNamespacesRoute","path":"admin/namespaces","declaration":{"file":"web/src/app/router.tsx","line":770},"implementation":{"file":"web/src/pages/admin/namespaces.tsx","line":104,"export":"AdminNamespacesPage"},"guarded":true,"roleProtected":true}
|
||||
{"name":"landingRoute","path":"/","declaration":{"file":"web/src/app/router.tsx","line":264},"implementation":{"file":"web/src/pages/landing.tsx","line":257,"export":"LandingPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"skillsRoute","path":"skills","declaration":{"file":"web/src/app/router.tsx","line":270},"implementation":{"file":"web/src/pages/home.tsx","line":12,"export":"HomePage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"loginRoute","path":"login","declaration":{"file":"web/src/app/router.tsx","line":276},"implementation":{"file":"web/src/pages/login.tsx","line":22,"export":"LoginPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"registerRoute","path":"register","declaration":{"file":"web/src/app/router.tsx","line":287},"implementation":{"file":"web/src/pages/register.tsx","line":55,"export":"RegisterPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"resetPasswordRoute","path":"reset-password","declaration":{"file":"web/src/app/router.tsx","line":296},"implementation":{"file":"web/src/pages/reset-password.tsx","line":13,"export":"ResetPasswordPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"privacyRoute","path":"privacy","declaration":{"file":"web/src/app/router.tsx","line":302},"implementation":{"file":"web/src/pages/privacy.tsx","line":155,"export":"PrivacyPolicyPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"searchRoute","path":"search","declaration":{"file":"web/src/app/router.tsx","line":308},"implementation":{"file":"web/src/pages/search.tsx","line":88,"export":"SearchPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"githubSkillsRoute","path":"github","declaration":{"file":"web/src/app/router.tsx","line":324},"implementation":{"file":"web/src/pages/github-skills.tsx","line":39,"export":"GithubSkillsPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"suitesRoute","path":"suites","declaration":{"file":"web/src/app/router.tsx","line":335},"implementation":{"file":"web/src/pages/suites.tsx","line":17,"export":"SuitesPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"suiteDetailRoute","path":"/suite/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":341},"implementation":{"file":"web/src/pages/suite-detail.tsx","line":22,"export":"SuiteDetailPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"termsRoute","path":"terms","declaration":{"file":"web/src/app/router.tsx","line":350},"implementation":{"file":"web/src/pages/terms.tsx","line":171,"export":"TermsOfServicePage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"namespaceRoute","path":"/space/$namespace","declaration":{"file":"web/src/app/router.tsx","line":356},"implementation":{"file":"web/src/pages/namespace.tsx","line":5,"export":"NamespacePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"joinRoute","path":"join","declaration":{"file":"web/src/app/router.tsx","line":363},"implementation":{"file":"web/src/pages/join.tsx","line":12,"export":"JoinPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"skillDetailRoute","path":"/space/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":370},"implementation":{"file":"web/src/pages/skill-detail.tsx","line":133,"export":"SkillDetailPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"skillVersionCompareRoute","path":"/space/$namespace/$slug/compare","declaration":{"file":"web/src/app/router.tsx","line":380},"implementation":{"file":"web/src/pages/skill-version-compare.tsx","line":22,"export":"SkillVersionComparePage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"dashboardRoute","path":"dashboard","declaration":{"file":"web/src/app/router.tsx","line":390},"implementation":{"file":"web/src/pages/dashboard.tsx","line":99,"export":"DashboardPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSkillsRoute","path":"dashboard/skills","declaration":{"file":"web/src/app/router.tsx","line":397},"implementation":{"file":"web/src/pages/dashboard/my-skills.tsx","line":46,"export":"MySkillsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardPublishRoute","path":"dashboard/publish","declaration":{"file":"web/src/app/router.tsx","line":410},"implementation":{"file":"web/src/pages/dashboard/publish.tsx","line":41,"export":"PublishPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteCreateRoute","path":"dashboard/suites/new","declaration":{"file":"web/src/app/router.tsx","line":436},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":716,"export":"SuiteCreatePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuitesRoute","path":"dashboard/suites","declaration":{"file":"web/src/app/router.tsx","line":443},"implementation":{"file":"web/src/pages/dashboard/my-suites.tsx","line":21,"export":"MySuitesPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteManagementRoute","path":"dashboard/suites/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":453},"implementation":{"file":"web/src/pages/dashboard/suite-management.tsx","line":31,"export":"SuiteManagementPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuitePublishingTaskRoute","path":"dashboard/suites/publishing/$operationId","declaration":{"file":"web/src/app/router.tsx","line":469},"implementation":{"file":"web/src/pages/dashboard/suite-publishing-task.tsx","line":6,"export":"SuitePublishingTaskPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteEditRoute","path":"dashboard/suites/$namespace/$slug/edit","declaration":{"file":"web/src/app/router.tsx","line":487},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":720,"export":"SuiteEditPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteVersionCreateRoute","path":"dashboard/suites/$namespace/$slug/new-version","declaration":{"file":"web/src/app/router.tsx","line":497},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":726,"export":"SuiteVersionCreatePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespacesRoute","path":"dashboard/namespaces","declaration":{"file":"web/src/app/router.tsx","line":509},"implementation":{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":18,"export":"MyNamespacesPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceManagementRoute","path":"dashboard/namespaces/$slug","declaration":{"file":"web/src/app/router.tsx","line":516},"implementation":{"file":"web/src/pages/dashboard/namespace-management.tsx","line":7,"export":"NamespaceManagementPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceSettingsRoute","path":"dashboard/namespaces/$slug/settings","declaration":{"file":"web/src/app/router.tsx","line":523},"implementation":{"file":"web/src/pages/dashboard/namespace-settings.tsx","line":14,"export":"NamespaceSettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceMembersRoute","path":"dashboard/namespaces/$slug/members","declaration":{"file":"web/src/app/router.tsx","line":530},"implementation":{"file":"web/src/pages/dashboard/namespace-members.tsx","line":45,"export":"NamespaceMembersPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceReviewsRoute","path":"dashboard/namespaces/$slug/reviews","declaration":{"file":"web/src/app/router.tsx","line":537},"implementation":{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":145,"export":"NamespaceReviewsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceGithubSourcesRoute","path":"dashboard/namespaces/$slug/github-sources","declaration":{"file":"web/src/app/router.tsx","line":547},"implementation":{"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":26,"export":"NamespaceGithubSourcesPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardGovernanceRoute","path":"dashboard/governance","declaration":{"file":"web/src/app/router.tsx","line":554},"implementation":{"file":"web/src/pages/dashboard/governance.tsx","line":40,"export":"GovernancePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardGithubSyncRoute","path":"dashboard/github-sync","declaration":{"file":"web/src/app/router.tsx","line":561},"implementation":{"file":"web/src/pages/dashboard/github-sync.tsx","line":20,"export":"GithubSyncPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"dashboardGithubDeepDiscoveryRoute","path":"dashboard/github-deep-discovery","declaration":{"file":"web/src/app/router.tsx","line":594},"implementation":null,"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardReviewsRoute","path":"dashboard/reviews","declaration":{"file":"web/src/app/router.tsx","line":603},"implementation":{"file":"web/src/pages/dashboard/reviews.tsx","line":39,"export":"ReviewsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardReviewProgressRoute","path":"dashboard/review-progress","declaration":{"file":"web/src/app/router.tsx","line":613},"implementation":{"file":"web/src/pages/dashboard/review-progress.tsx","line":27,"export":"ReviewProgressPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardReportsRoute","path":"dashboard/reports","declaration":{"file":"web/src/app/router.tsx","line":635},"implementation":{"file":"web/src/pages/dashboard/reports.tsx","line":31,"export":"ReportsPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"dashboardReviewDetailRoute","path":"dashboard/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":642},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":530,"export":"ReviewDetailPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceReviewDetailRoute","path":"dashboard/namespaces/$slug/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":649},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":542,"export":"NamespaceReviewDetailPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardPromotionsRoute","path":"dashboard/promotions","declaration":{"file":"web/src/app/router.tsx","line":656},"implementation":{"file":"web/src/pages/dashboard/promotions.tsx","line":408,"export":"PromotionsPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"dashboardStarsRoute","path":"dashboard/stars","declaration":{"file":"web/src/app/router.tsx","line":663},"implementation":{"file":"web/src/pages/dashboard/stars.tsx","line":12,"export":"MyStarsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSubscriptionsRoute","path":"dashboard/subscriptions","declaration":{"file":"web/src/app/router.tsx","line":673},"implementation":{"file":"web/src/pages/dashboard/subscriptions.tsx","line":12,"export":"MySubscriptionsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNotificationsRoute","path":"dashboard/notifications","declaration":{"file":"web/src/app/router.tsx","line":683},"implementation":{"file":"web/src/pages/notifications.tsx","line":48,"export":"NotificationsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardTokensRoute","path":"dashboard/tokens","declaration":{"file":"web/src/app/router.tsx","line":690},"implementation":{"file":"web/src/pages/dashboard/tokens.tsx","line":8,"export":"TokensPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"authoringDraftsRoute","path":"dashboard/authoring","declaration":{"file":"web/src/app/router.tsx","line":697},"implementation":{"file":"web/src/pages/authoring/drafts.tsx","line":46,"export":"DraftsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"authoringDraftDetailRoute","path":"dashboard/authoring/$draftId","declaration":{"file":"web/src/app/router.tsx","line":704},"implementation":{"file":"web/src/pages/authoring/draft-detail.tsx","line":15,"export":"DraftDetailPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"cliAuthRoute","path":"cli/auth","declaration":{"file":"web/src/app/router.tsx","line":711},"implementation":{"file":"web/src/pages/cli-auth.tsx","line":48,"export":"CliAuthPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"deviceAuthRoute","path":"device","declaration":{"file":"web/src/app/router.tsx","line":726},"implementation":{"file":"web/src/pages/device.tsx","line":20,"export":"DeviceAuthPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"settingsSecurityRoute","path":"settings/security","declaration":{"file":"web/src/app/router.tsx","line":732},"implementation":{"file":"web/src/pages/settings/security.tsx","line":28,"export":"SecuritySettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"settingsProfileRoute","path":"settings/profile","declaration":{"file":"web/src/app/router.tsx","line":739},"implementation":{"file":"web/src/pages/settings/profile.tsx","line":40,"export":"ProfileSettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"settingsNotificationsRoute","path":"settings/notifications","declaration":{"file":"web/src/app/router.tsx","line":746},"implementation":{"file":"web/src/pages/settings/notification-settings.tsx","line":8,"export":"NotificationSettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"settingsAccountsRoute","path":"settings/accounts","declaration":{"file":"web/src/app/router.tsx","line":753},"implementation":null,"guarded":true,"roleProtected":false},
|
||||
{"name":"adminUsersRoute","path":"admin/users","declaration":{"file":"web/src/app/router.tsx","line":762},"implementation":{"file":"web/src/pages/admin/users.tsx","line":47,"export":"AdminUsersPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"adminAuditLogRoute","path":"admin/audit-log","declaration":{"file":"web/src/app/router.tsx","line":769},"implementation":{"file":"web/src/pages/admin/audit-log.tsx","line":50,"export":"AuditLogPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"adminLabelsRoute","path":"admin/labels","declaration":{"file":"web/src/app/router.tsx","line":776},"implementation":{"file":"web/src/pages/admin/labels.tsx","line":118,"export":"AdminLabelsPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"systemConfigRoute","path":"admin/system-config","declaration":{"file":"web/src/app/router.tsx","line":783},"implementation":{"file":"web/src/pages/admin/system-config.tsx","line":13,"export":"SystemConfigPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"adminNamespacesRoute","path":"admin/namespaces","declaration":{"file":"web/src/app/router.tsx","line":790},"implementation":{"file":"web/src/pages/admin/namespaces.tsx","line":104,"export":"AdminNamespacesPage"},"guarded":true,"roleProtected":true}
|
||||
],
|
||||
"definitions": [
|
||||
{"file":"web/src/app/layout.tsx","line":89,"ids":["navigation.home.open"]},
|
||||
|
|
@ -78,16 +80,17 @@
|
|||
{"file":"web/src/pages/dashboard.tsx","line":52,"ids":["navigation.starred.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":53,"ids":["navigation.subscriptions.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":54,"ids":["account.tokens.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":55,"ids":["review.progress.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":61,"ids":["governance.center.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":62,"ids":["report.queue.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":63,"ids":["github.sync.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":87,"ids":["navigation.owned.skills"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":88,"ids":["navigation.owned.suites"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":89,"ids":["account.tokens.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":90,"ids":["navigation.starred.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":91,"ids":["account.profile.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":92,"ids":["account.security.open"]}
|
||||
{"file":"web/src/pages/dashboard.tsx","line":55,"ids":["navigation.authoring.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":56,"ids":["review.progress.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":62,"ids":["governance.center.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":63,"ids":["report.queue.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":64,"ids":["github.sync.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":88,"ids":["navigation.owned.skills"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":89,"ids":["navigation.owned.suites"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":90,"ids":["account.tokens.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":91,"ids":["navigation.starred.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":92,"ids":["account.profile.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":93,"ids":["account.security.open"]}
|
||||
],
|
||||
"sites": [
|
||||
{"file":"web/src/app/layout.tsx","line":126,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]},
|
||||
|
|
@ -758,10 +761,10 @@
|
|||
{"file":"web/src/pages/dashboard/suite-management.tsx","line":210,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.content.edit"]},
|
||||
{"file":"web/src/pages/dashboard/suite-management.tsx","line":236,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.members.edit"]},
|
||||
{"file":"web/src/pages/dashboard/suite-management.tsx","line":333,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.operation.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":128,"host":"Link","prop":"data-analytics-id","event":"click","forward":"card.analyticsId"},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":176,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.toggle"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":194,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":245,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":129,"host":"Link","prop":"data-analytics-id","event":"click","forward":"card.analyticsId"},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":177,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.toggle"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":195,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":246,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
|
||||
{"file":"web/src/pages/device.tsx","line":106,"host":"form","prop":"data-analytics-id","event":"submit","ids":["auth.device.authorize"]},
|
||||
{"file":"web/src/pages/github-skills.tsx","line":33,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]},
|
||||
{"file":"web/src/pages/github-skills.tsx","line":62,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.catalog.explore"]},
|
||||
|
|
|
|||
93
web/e2e/authoring-structure.spec.ts
Normal file
93
web/e2e/authoring-structure.spec.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures'
|
||||
import { registerSession } from './helpers/session'
|
||||
import { E2eTestDataBuilder } from './helpers/test-data-builder'
|
||||
import { csrfHeaders } from './helpers/csrf'
|
||||
|
||||
test('authoring routes require a session', async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
await page.goto('/dashboard/authoring')
|
||||
await expect(page).toHaveURL(/\/login\?returnTo=/)
|
||||
})
|
||||
|
||||
test('creates a draft, edits files and rechecks structure using merged APIs', async ({ page }, testInfo) => {
|
||||
await setEnglishLocale(page)
|
||||
await setUniqueClientIp(page, testInfo.title)
|
||||
await registerSession(page, testInfo, { allowMockSession: false })
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
await builder.init()
|
||||
const namespace = await builder.ensureWritableNamespace()
|
||||
const name = `structure-${Date.now().toString(36)}`
|
||||
let draftId: number | undefined
|
||||
const authoringRequests: string[] = []
|
||||
page.on('request', request => { if (request.url().includes('/authoring/')) authoringRequests.push(request.url()) })
|
||||
try {
|
||||
await page.goto('/dashboard/authoring')
|
||||
await page.getByTestId('create-draft').click()
|
||||
await page.getByTestId('draft-namespace').click()
|
||||
await page.getByRole('option', { name: namespace.displayName || namespace.slug, exact: true }).click()
|
||||
await page.getByLabel('Skill name', { exact: true }).fill(name)
|
||||
const createdResponse = page.waitForResponse(response => response.url().endsWith('/api/web/authoring/drafts') && response.request().method() === 'POST')
|
||||
await page.getByTestId('submit-create-draft').click()
|
||||
const created = await (await createdResponse).json()
|
||||
draftId = created.data.id
|
||||
expect(draftId).toBeDefined()
|
||||
await page.getByRole('link', { name, exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible()
|
||||
const editor = page.getByLabel('File content', { exact: true })
|
||||
await expect(editor).toBeVisible()
|
||||
await editor.fill(`---\nname: ${name}\n---\n\n# Structure test\n`)
|
||||
await expect(page.getByTestId('start-run')).toBeDisabled()
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(page.getByTestId('start-run')).toBeEnabled()
|
||||
await page.getByTestId('start-run').click()
|
||||
await expect(page.getByTestId('validation-results').getByText(/Failed · revision/)).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page.getByText(/FRONTMATTER_FIELD_MISSING/)).toBeVisible()
|
||||
await editor.fill(`---\nname: ${name}\ndescription: A structure validation test skill\n---\n\n# Structure test\n\nUse this skill to test package validation.\n`)
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(page.getByTestId('start-run')).toBeEnabled()
|
||||
await page.getByTestId('start-run').click()
|
||||
await expect(page.getByTestId('validation-results').getByText(/Succeeded · revision/)).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page.getByText(/Structure checked/)).toBeVisible()
|
||||
// A successful structural check must not introduce runtime, fix or publish actions.
|
||||
expect(authoringRequests.some(url => /\/(runtime|submit|apply|dismiss)(?:\?|$)/.test(url))).toBe(false)
|
||||
const persisted = await page.request.get(`/api/web/authoring/drafts/${draftId}`)
|
||||
expect((await persisted.json()).data.validated).toBe(true)
|
||||
|
||||
// Transport encoding, rather than filename guessing, keeps binary bytes out of the editor.
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg==', 'base64')
|
||||
await page.getByLabel('Path', { exact: true }).fill('assets/pixel.png')
|
||||
await page.getByLabel('Upload file', { exact: true }).setInputFiles({ name: 'pixel.png', mimeType: 'image/png', buffer: png })
|
||||
await page.getByRole('button', { name: 'Upload', exact: true }).click()
|
||||
await expect(page.getByTestId('binary-file-panel')).toBeVisible()
|
||||
const binary = await page.request.get(`/api/web/authoring/drafts/${draftId}/files/content?path=assets%2Fpixel.png`)
|
||||
const binaryFile = (await binary.json()).data
|
||||
expect(binaryFile.encoding).toBe('base64')
|
||||
expect(Buffer.from(binaryFile.content, 'base64').equals(png)).toBe(true)
|
||||
const invalidated = await page.request.get(`/api/web/authoring/drafts/${draftId}`)
|
||||
expect((await invalidated.json()).data.validated).toBe(false)
|
||||
|
||||
// Private draft and content are also refused by the real API without this session.
|
||||
const stranger = await page.context().browser()!.newContext({ baseURL: testInfo.project.use.baseURL })
|
||||
try {
|
||||
expect((await stranger.request.get(`/api/web/authoring/drafts/${draftId}`)).status()).toBe(401)
|
||||
expect((await stranger.request.get(`/api/web/authoring/drafts/${draftId}/files/content?path=SKILL.md`)).status()).toBe(401)
|
||||
} finally { await stranger.close() }
|
||||
|
||||
for (const [theme, width, density] of [['light', 1440, 'regular'], ['dark', 390, 'compact']] as const) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.evaluate(({ theme, density }) => {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||
document.documentElement.dataset.density = density
|
||||
}, { theme, density })
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
|
||||
await page.screenshot({ path: testInfo.outputPath(`structure-${theme}-${width}.png`), fullPage: true })
|
||||
}
|
||||
} finally {
|
||||
if (draftId != null) {
|
||||
const removed = await page.request.delete(`/api/web/authoring/drafts/${draftId}`, { headers: await csrfHeaders(page) })
|
||||
expect(removed.ok()).toBeTruthy()
|
||||
}
|
||||
await builder.cleanup()
|
||||
}
|
||||
})
|
||||
29
web/src/api/authoring.test.ts
Normal file
29
web/src/api/authoring.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { authoringApi } from './client'
|
||||
import { ApiError } from '@/shared/lib/api-error'
|
||||
const fetchMock = vi.fn()
|
||||
beforeEach(() => { vi.stubGlobal('fetch', fetchMock); fetchMock.mockReset(); document.cookie = 'XSRF-TOKEN=fixture-csrf; path=/' })
|
||||
afterEach(() => { document.cookie = 'XSRF-TOKEN=; Max-Age=0; path=/'; vi.unstubAllGlobals() })
|
||||
describe('authoring request boundary', () => {
|
||||
it('keeps binary encoding, revision and CSRF on file writes', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({ code: 0, msg: 'ok', data: { created: true } })))
|
||||
await authoringApi.saveFile(3, { path: 'assets/raw.bin', content: '/wAB', base64: true, expectedRevision: 7 })
|
||||
const [url, request] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('/api/web/authoring/drafts/3/files')
|
||||
expect(request.method).toBe('PUT')
|
||||
expect(new Headers(request.headers).get('X-XSRF-TOKEN')).toBe('fixture-csrf')
|
||||
expect(JSON.parse(request.body)).toEqual({ path: 'assets/raw.bin', content: '/wAB', encoding: 'base64', expectedRevision: 7 })
|
||||
})
|
||||
it('encodes file paths without losing separators, unicode or query punctuation', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({ code: 0, msg: 'ok', data: { content: 'hello', encoding: 'text' } })))
|
||||
await authoringApi.readFile(3, 'references/示例 & notes.md')
|
||||
const url = new URL(fetchMock.mock.calls[0][0], 'http://localhost')
|
||||
expect(url.searchParams.get('path')).toBe('references/示例 & notes.md')
|
||||
expect([...url.searchParams.keys()]).toEqual(['path'])
|
||||
})
|
||||
it('preserves server conflict errors instead of treating saves as successful', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({ code: 409, msg: 'Revision conflict', data: null }), { status: 409 }))
|
||||
await expect(authoringApi.saveFile(3, { path: 'SKILL.md', content: 'edit', expectedRevision: 1 })).rejects.toMatchObject({ status: 409, message: 'Revision conflict' } satisfies Partial<ApiError>)
|
||||
})
|
||||
})
|
||||
|
|
@ -62,6 +62,12 @@ import type {
|
|||
BatchMemberResponse,
|
||||
SkillSuite,
|
||||
SkillSuiteDraftInput,
|
||||
AuthoringDraft,
|
||||
DraftFileSummary,
|
||||
DraftFileContent,
|
||||
SaveDraftFileOutcome,
|
||||
ValidationRunInfo,
|
||||
ValidationFindingInfo,
|
||||
} from './types'
|
||||
import { ApiError } from '@/shared/lib/api-error'
|
||||
import i18n from '@/i18n/config'
|
||||
|
|
@ -2282,3 +2288,101 @@ export const notificationApi = {
|
|||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const authoringApi = {
|
||||
async listDrafts(): Promise<AuthoringDraft[]> {
|
||||
return fetchJson<AuthoringDraft[]>(`${WEB_API_PREFIX}/authoring/drafts`)
|
||||
},
|
||||
|
||||
async createDraft(request: components['schemas']['CreateDraftRequest']): Promise<AuthoringDraft> {
|
||||
return fetchJson<AuthoringDraft>(`${WEB_API_PREFIX}/authoring/drafts`, {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
},
|
||||
|
||||
async getDraft(draftId: number): Promise<AuthoringDraft> {
|
||||
return fetchJson<AuthoringDraft>(`${WEB_API_PREFIX}/authoring/drafts/${draftId}`)
|
||||
},
|
||||
|
||||
async deleteDraft(draftId: number): Promise<void> {
|
||||
await fetchJson<null>(`${WEB_API_PREFIX}/authoring/drafts/${draftId}`, {
|
||||
method: 'DELETE',
|
||||
headers: await ensureCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async listFiles(draftId: number): Promise<DraftFileSummary[]> {
|
||||
return fetchJson<DraftFileSummary[]>(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files`)
|
||||
},
|
||||
|
||||
async readFile(draftId: number, path: string): Promise<DraftFileContent> {
|
||||
const query = new URLSearchParams({ path })
|
||||
return fetchJson<DraftFileContent>(
|
||||
`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files/content?${query.toString()}`,
|
||||
)
|
||||
},
|
||||
|
||||
async saveFile(
|
||||
draftId: number,
|
||||
request: Omit<components['schemas']['SaveDraftFileRequest'], 'encoding'> & { base64?: boolean },
|
||||
): Promise<SaveDraftFileOutcome> {
|
||||
return fetchJson<SaveDraftFileOutcome>(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files`, {
|
||||
method: 'PUT',
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
path: request.path,
|
||||
content: request.content,
|
||||
encoding: request.base64 ? 'base64' : undefined,
|
||||
contentType: request.contentType,
|
||||
expectedRevision: request.expectedRevision,
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
async deleteFile(draftId: number, path: string, expectedRevision?: number): Promise<void> {
|
||||
const query = new URLSearchParams({ path })
|
||||
if (expectedRevision !== undefined) {
|
||||
query.set('expectedRevision', String(expectedRevision))
|
||||
}
|
||||
await fetchJson<null>(
|
||||
`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files?${query.toString()}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: await ensureCsrfHeaders(),
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
async startRun(draftId: number): Promise<ValidationRunInfo> {
|
||||
return fetchJson<ValidationRunInfo>(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runs`, {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async listRuns(draftId: number): Promise<ValidationRunInfo[]> {
|
||||
return fetchJson<ValidationRunInfo[]>(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runs`)
|
||||
},
|
||||
|
||||
async getRun(runId: number): Promise<ValidationRunInfo> {
|
||||
return fetchJson<ValidationRunInfo>(`${WEB_API_PREFIX}/authoring/runs/${runId}`)
|
||||
},
|
||||
|
||||
async cancelRun(runId: number): Promise<ValidationRunInfo> {
|
||||
return fetchJson<ValidationRunInfo>(`${WEB_API_PREFIX}/authoring/runs/${runId}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async listFindings(runId: number): Promise<ValidationFindingInfo[]> {
|
||||
return fetchJson<ValidationFindingInfo[]>(`${WEB_API_PREFIX}/authoring/runs/${runId}/findings`)
|
||||
},
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -885,3 +885,11 @@ export type NamespaceInvitationHistory = Required<components['schemas']['Invitat
|
|||
export type NamespaceInvitationHistoryPage = Omit<Required<components['schemas']['InvitationHistoryPage']>, 'items'> & {
|
||||
items: NamespaceInvitationHistory[]
|
||||
}
|
||||
|
||||
// Existing authoring API contracts; no backend/schema changes are needed for the workbench.
|
||||
export type AuthoringDraft = components['schemas']['DraftResponse']
|
||||
export type DraftFileSummary = components['schemas']['DraftFileResponse']
|
||||
export type DraftFileContent = components['schemas']['DraftFileContentResponse']
|
||||
export type SaveDraftFileOutcome = components['schemas']['SaveDraftFileResponse']
|
||||
export type ValidationRunInfo = components['schemas']['ValidationRunResponse']
|
||||
export type ValidationFindingInfo = components['schemas']['ValidationFindingResponse']
|
||||
|
|
|
|||
|
|
@ -195,6 +195,12 @@ const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/sta
|
|||
const MySubscriptionsPage = createLazyRouteComponent(() => import('@/pages/dashboard/subscriptions'), 'MySubscriptionsPage', dashboardRouteOptions)
|
||||
const NotificationsPage = createLazyRouteComponent(() => import('@/pages/notifications'), 'NotificationsPage')
|
||||
const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage', dashboardRouteOptions)
|
||||
const AuthoringDraftsPage = createLazyRouteComponent(() => import('@/pages/authoring/drafts'), 'DraftsPage', dashboardRouteOptions)
|
||||
const AuthoringDraftDetailPage = createLazyRouteComponent(
|
||||
() => import('@/pages/authoring/draft-detail'),
|
||||
'DraftDetailPage',
|
||||
dashboardRouteOptions,
|
||||
)
|
||||
const CliAuthPage = createLazyRouteComponent(() => import('@/pages/cli-auth'), 'CliAuthPage')
|
||||
const SecuritySettingsPage = createLazyRouteComponent(
|
||||
() => import('@/pages/settings/security'),
|
||||
|
|
@ -688,6 +694,20 @@ const dashboardTokensRoute = createRoute({
|
|||
component: TokensPage,
|
||||
})
|
||||
|
||||
const authoringDraftsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'dashboard/authoring',
|
||||
beforeLoad: requireAuth,
|
||||
component: AuthoringDraftsPage,
|
||||
})
|
||||
|
||||
const authoringDraftDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'dashboard/authoring/$draftId',
|
||||
beforeLoad: requireAuth,
|
||||
component: AuthoringDraftDetailPage,
|
||||
})
|
||||
|
||||
const cliAuthRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'cli/auth',
|
||||
|
|
@ -818,6 +838,8 @@ const routeTree = rootRoute.addChildren([
|
|||
dashboardSubscriptionsRoute,
|
||||
dashboardNotificationsRoute,
|
||||
dashboardTokensRoute,
|
||||
authoringDraftsRoute,
|
||||
authoringDraftDetailRoute,
|
||||
cliAuthRoute,
|
||||
deviceAuthRoute,
|
||||
settingsSecurityRoute,
|
||||
|
|
|
|||
10
web/src/features/authoring/binary-file.test.ts
Normal file
10
web/src/features/authoring/binary-file.test.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { formatBytes } from './binary-file'
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats byte sizes for humans', () => {
|
||||
expect(formatBytes(512)).toBe('512 B')
|
||||
expect(formatBytes(2048)).toBe('2.0 KB')
|
||||
expect(formatBytes(3 * 1024 * 1024)).toBe('3.0 MB')
|
||||
})
|
||||
})
|
||||
35
web/src/features/authoring/binary-file.ts
Normal file
35
web/src/features/authoring/binary-file.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export function formatBytes(size: number): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`
|
||||
}
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/** Reads a browser File as base64 (no data: prefix) for the API's base64 encoding. */
|
||||
export function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = () => reject(reader.error ?? new Error('failed to read file'))
|
||||
reader.onload = () => {
|
||||
const buffer = reader.result
|
||||
if (!(buffer instanceof ArrayBuffer)) {
|
||||
reject(new Error('failed to read file'))
|
||||
return
|
||||
}
|
||||
resolve(bytesToBase64(new Uint8Array(buffer)))
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
const chunkSize = 0x8000
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize))
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
99
web/src/features/authoring/draft-file-editor.test.tsx
Normal file
99
web/src/features/authoring/draft-file-editor.test.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { authoringApi } from '@/api/client'
|
||||
import { DraftFileEditor } from './draft-file-editor'
|
||||
import i18n from '@/i18n/config'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({ useBlocker: () => ({ status: 'idle' }) }))
|
||||
vi.mock('@/api/client', () => ({ authoringApi: { getDraft: vi.fn(), listFiles: vi.fn(), readFile: vi.fn(), saveFile: vi.fn(), deleteFile: vi.fn() } }))
|
||||
vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
const clients: QueryClient[] = []
|
||||
function mount() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
clients.push(client)
|
||||
const props = { draftId: 42, revision: 3, files: [{ path: 'SKILL.md', sha256: 'old', size: 4 }, { path: 'scripts/test.sh', sha256: 'script', size: 2 }], onSaved: vi.fn(), onDirtyChange: vi.fn() }
|
||||
const view = render(<QueryClientProvider client={client}><DraftFileEditor {...props} /></QueryClientProvider>)
|
||||
return { ...view, props, client }
|
||||
}
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
await i18n.changeLanguage('en')
|
||||
vi.mocked(authoringApi.getDraft).mockResolvedValue({ revision: 3 })
|
||||
vi.mocked(authoringApi.readFile).mockImplementation(async (_id, path) => ({ path, content: path === 'SKILL.md' ? 'original' : 'echo ok', encoding: 'text', sha256: path }))
|
||||
vi.mocked(authoringApi.saveFile).mockResolvedValue({ created: true, draft: { revision: 4 } })
|
||||
})
|
||||
afterEach(() => { cleanup(); clients.forEach(client => client.clear()); clients.length = 0 })
|
||||
|
||||
describe('draft files', () => {
|
||||
it('keeps edits after a conflict and uses the revision captured with the read', async () => {
|
||||
const { props, rerender, client } = mount()
|
||||
const editor = await screen.findByLabelText('File content')
|
||||
fireEvent.change(editor, { target: { value: 'my edit' } })
|
||||
rerender(<QueryClientProvider client={client}><DraftFileEditor {...props} revision={9} /></QueryClientProvider>)
|
||||
vi.mocked(authoringApi.saveFile).mockRejectedValueOnce(new Error('Revision conflict'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(authoringApi.saveFile).toHaveBeenCalledWith(42, { path: 'SKILL.md', content: 'my edit', expectedRevision: 3 }))
|
||||
await waitFor(() => expect((editor as HTMLTextAreaElement).disabled).toBe(false))
|
||||
expect((screen.getByLabelText('File content') as HTMLTextAreaElement).value).toBe('my edit')
|
||||
expect(screen.getByText('Unsaved changes')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('requires confirmation before switching away from a dirty file', async () => {
|
||||
mount()
|
||||
fireEvent.change(await screen.findByLabelText('File content'), { target: { value: 'my edit' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'scripts/test.sh' }))
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect((screen.getByLabelText('File content') as HTMLTextAreaElement).value).toBe('my edit')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'scripts/test.sh' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }))
|
||||
await waitFor(() => expect((screen.getByLabelText('File content') as HTMLTextAreaElement).value).toBe('echo ok'))
|
||||
})
|
||||
|
||||
it('does not create an empty file over an existing path', async () => {
|
||||
mount()
|
||||
await screen.findByLabelText('File content')
|
||||
fireEvent.change(screen.getByLabelText('Path'), { target: { value: 'skill.md' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create file' }))
|
||||
expect(authoringApi.saveFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to overwrite a file absent from the cached list', async () => {
|
||||
vi.mocked(authoringApi.listFiles).mockResolvedValue([{ path: 'new.md' }])
|
||||
mount()
|
||||
await screen.findByLabelText('File content')
|
||||
fireEvent.change(screen.getByLabelText('Path'), { target: { value: 'new.md' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create file' }))
|
||||
await waitFor(() => expect(authoringApi.listFiles).toHaveBeenCalledWith(42))
|
||||
await waitFor(() => expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create file' }).disabled).toBe(false))
|
||||
expect(authoringApi.saveFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses server encoding rather than an extension to protect binary bytes', async () => {
|
||||
vi.mocked(authoringApi.readFile).mockResolvedValue({ content: '/wAB', encoding: 'base64', sha256: 'binary' })
|
||||
mount()
|
||||
await screen.findByTestId('binary-file-panel')
|
||||
expect(screen.queryByLabelText('File content')).toBeNull()
|
||||
expect(screen.queryByText('/wAB')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a retry on read failure instead of an editable empty file', async () => {
|
||||
vi.mocked(authoringApi.readFile).mockRejectedValueOnce(new Error('unavailable'))
|
||||
mount()
|
||||
await screen.findByRole('alert')
|
||||
expect(screen.queryByLabelText('File content')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByLabelText('File content')
|
||||
})
|
||||
|
||||
it('can discard edits and reload even when the server bytes did not change', async () => {
|
||||
mount()
|
||||
fireEvent.change(await screen.findByLabelText('File content'), { target: { value: 'my edit' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reload file' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }))
|
||||
await waitFor(() => expect((screen.getByLabelText('File content') as HTMLTextAreaElement).value).toBe('original'))
|
||||
expect(screen.queryByText('Unsaved changes')).toBeNull()
|
||||
})
|
||||
})
|
||||
166
web/src/features/authoring/draft-file-editor.tsx
Normal file
166
web/src/features/authoring/draft-file-editor.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useBlocker } from '@tanstack/react-router'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { authoringApi } from '@/api/client'
|
||||
import type { DraftFileSummary } from '@/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { fileToBase64, formatBytes } from './binary-file'
|
||||
import { QueryError } from './query-error'
|
||||
|
||||
export function DraftFileEditor({ draftId, files, revision, onSaved, onDirtyChange }: {
|
||||
draftId: number, files: DraftFileSummary[], revision: number,
|
||||
onSaved: () => void, onDirtyChange: (dirty: boolean) => void,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const client = useQueryClient()
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [newPath, setNewPath] = useState('')
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [discardPath, setDiscardPath] = useState<string | null>(null)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [reloadOpen, setReloadOpen] = useState(false)
|
||||
const [editorGeneration, setEditorGeneration] = useState(0)
|
||||
const paths = files.flatMap(file => file.path ? [file.path] : []).sort()
|
||||
const path = selectedPath && paths.includes(selectedPath) ? selectedPath : paths[0] ?? null
|
||||
const selected = files.find(file => file.path === path)
|
||||
const readKey = ['authoring', 'draft', draftId, 'content', path]
|
||||
const read = useQuery({
|
||||
queryKey: readKey,
|
||||
queryFn: async () => {
|
||||
if (!path) throw new Error(t('authoring.files.empty'))
|
||||
// Capture the revision at the start of this read; later refreshes must not
|
||||
// silently authorize an edit based on stale bytes against a newer revision.
|
||||
const before = await authoringApi.getDraft(draftId)
|
||||
if (before.revision == null) throw new Error(t('authoring.ui.loadFailed'))
|
||||
const file = await authoringApi.readFile(draftId, path)
|
||||
return { ...file, revision: before.revision }
|
||||
},
|
||||
enabled: path != null, meta: { skipGlobalErrorHandler: true }, retry: false,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
const loadedFile = read.data
|
||||
const blocker = useBlocker({ shouldBlockFn: () => dirty || busy, enableBeforeUnload: dirty || busy, withResolver: true })
|
||||
useEffect(() => { onDirtyChange(dirty || busy) }, [dirty, busy, onDirtyChange])
|
||||
|
||||
const choosePath = (next: string) => {
|
||||
if (next === path) return
|
||||
if (dirty) setDiscardPath(next)
|
||||
else setSelectedPath(next)
|
||||
}
|
||||
const mutateFile = async (operation: () => Promise<unknown>, successKey: string, nextPath?: string) => {
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await operation()
|
||||
setDirty(false)
|
||||
if (nextPath) setSelectedPath(nextPath)
|
||||
await client.invalidateQueries({ queryKey: ['authoring', 'draft', draftId, 'content'] })
|
||||
onSaved()
|
||||
toast.success(t(successKey))
|
||||
return true
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('apiError.unknown'))
|
||||
return false
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
const create = async () => {
|
||||
const next = newPath.trim().replace(/\\/g, '/').replace(/(^|\/)skill\.md$/i, '$1SKILL.md')
|
||||
if (!next || busy || dirty) return
|
||||
// Creating a file must never overwrite an existing resource with empty bytes.
|
||||
if (paths.includes(next)) {
|
||||
toast.error(t('authoring.files.exists'))
|
||||
return
|
||||
}
|
||||
const created = await mutateFile(async () => {
|
||||
// Read the revision before listing files. Any concurrent creation after
|
||||
// that revision either appears in the list or makes the write conflict.
|
||||
const snapshot = await authoringApi.getDraft(draftId)
|
||||
if (snapshot.revision == null) throw new Error(t('authoring.ui.loadFailed'))
|
||||
const latestFiles = await authoringApi.listFiles(draftId)
|
||||
if (latestFiles.some(file => file.path === next)) {
|
||||
throw new Error(t('authoring.files.exists'))
|
||||
}
|
||||
return authoringApi.saveFile(draftId, {
|
||||
path: next, content: '', expectedRevision: snapshot.revision,
|
||||
})
|
||||
}, 'authoring.files.created', next)
|
||||
if (created) setNewPath('')
|
||||
}
|
||||
const upload = async () => {
|
||||
if (!uploadFile || !newPath.trim() || busy || dirty) return
|
||||
const file = uploadFile
|
||||
const next = newPath.trim()
|
||||
const ok = await mutateFile(async () => authoringApi.saveFile(draftId, {
|
||||
path: next, content: await fileToBase64(file), base64: true,
|
||||
contentType: file.type || undefined, expectedRevision: revision,
|
||||
}), 'authoring.files.uploaded', next)
|
||||
if (ok) { setUploadFile(null); setNewPath('') }
|
||||
}
|
||||
return (
|
||||
<div className="grid min-w-0 gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<ul className="space-y-1" aria-label={t('authoring.files.title')}>
|
||||
{paths.map(name => <li key={name}><button type="button" disabled={busy} aria-pressed={path === name} title={name} onClick={() => choosePath(name)} className={`w-full break-all rounded-md px-2 py-2 text-left font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${path === name ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}>{name}</button></li>)}
|
||||
</ul>
|
||||
<Label htmlFor="new-file-path">{t('authoring.files.path')}</Label>
|
||||
<Input id="new-file-path" value={newPath} disabled={busy || dirty} onChange={event => setNewPath(event.target.value)} placeholder="scripts/run.py" />
|
||||
<Button size="sm" variant="outline" onClick={() => void create()} disabled={busy || dirty || !newPath.trim()}>{t('authoring.files.create')}</Button>
|
||||
<Label htmlFor="upload-file">{t('authoring.files.upload')}</Label>
|
||||
<Input id="upload-file" type="file" disabled={busy || dirty} onChange={event => {
|
||||
const file = event.target.files?.[0] ?? null
|
||||
setUploadFile(file)
|
||||
if (file && !newPath.trim()) setNewPath(`assets/${file.name}`)
|
||||
}} />
|
||||
<p className="text-xs text-muted-foreground">{t('authoring.files.replaceHint')}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => void upload()} disabled={busy || dirty || !uploadFile || !newPath.trim()}>{t(busy ? 'authoring.files.uploading' : 'authoring.files.uploadConfirm')}</Button>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
{!path && <p>{t('authoring.files.empty')}</p>}
|
||||
{path && read.isPending && <p role="status">{t('authoring.ui.loading')}</p>}
|
||||
{path && read.isError && <QueryError error={read.error} retry={() => void read.refetch()} />}
|
||||
{path && loadedFile && (loadedFile.encoding === 'text' ? <TextFileEditor key={`${draftId}:${path}:${loadedFile.sha256}:${editorGeneration}`} content={loadedFile.content ?? ''} disabled={busy} onDirtyChange={setDirty} onSave={content => mutateFile(() => authoringApi.saveFile(draftId, { path, content, expectedRevision: loadedFile.revision }), 'authoring.files.saved')} /> : <div className="space-y-2 rounded-md border p-4" data-testid="binary-file-panel">
|
||||
<p className="break-all font-mono text-sm">{path}</p>
|
||||
<p className="text-sm">{t('authoring.files.size')}: {formatBytes(selected?.size ?? 0)}</p>
|
||||
<p className="break-all font-mono text-xs">sha256: {selected?.sha256}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('authoring.files.binaryHint')}</p>
|
||||
</div>)}
|
||||
{path && <Button variant="outline" size="sm" disabled={busy} onClick={() => dirty ? setReloadOpen(true) : void read.refetch()}>{t('authoring.files.reload')}</Button>}
|
||||
{path && <Button variant="outline" size="sm" disabled={busy} onClick={() => setDeleteOpen(true)}>{t('authoring.files.delete')}</Button>}
|
||||
</div>
|
||||
<ConfirmDialog open={discardPath != null} onOpenChange={open => { if (!open) setDiscardPath(null) }} title={t('authoring.files.discardTitle')} description={t('authoring.files.discardDescription')} onConfirm={() => { setDirty(false); setSelectedPath(discardPath); setDiscardPath(null) }} />
|
||||
<ConfirmDialog open={blocker.status === 'blocked'} onOpenChange={open => { if (!open) blocker.reset?.() }} title={t('authoring.files.discardTitle')} description={t('authoring.files.discardDescription')} onConfirm={() => { if (!busy) blocker.proceed?.() }} closeOnConfirm={false} />
|
||||
<ConfirmDialog open={reloadOpen} onOpenChange={setReloadOpen} title={t('authoring.files.discardTitle')} description={t('authoring.files.discardDescription')} onConfirm={async () => { const result = await read.refetch(); if (result.isSuccess) { setEditorGeneration(value => value + 1); setDirty(false); setReloadOpen(false) } }} />
|
||||
<ConfirmDialog open={deleteOpen} onOpenChange={setDeleteOpen} title={t('authoring.files.deleteTitle')} description={t('authoring.files.deleteDescription', { path })} variant="destructive" closeOnConfirm={false} onConfirm={async () => {
|
||||
if (!path) return
|
||||
if (await mutateFile(() => authoringApi.deleteFile(draftId, path, read.data?.revision ?? revision), 'authoring.files.deleted')) { setDeleteOpen(false); setSelectedPath(null) }
|
||||
}} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TextFileEditor({ content: initial, disabled, onDirtyChange, onSave }: {
|
||||
content: string, disabled: boolean, onDirtyChange: (dirty: boolean) => void,
|
||||
onSave: (content: string) => Promise<boolean | undefined>,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [content, setContent] = useState(initial)
|
||||
const [saved, setSaved] = useState(initial)
|
||||
const dirty = content !== saved
|
||||
useEffect(() => { onDirtyChange(dirty); return () => onDirtyChange(false) }, [dirty, onDirtyChange])
|
||||
return <div className="space-y-2">
|
||||
<Label htmlFor="draft-file-content">{t('authoring.files.content')}</Label>
|
||||
<Textarea id="draft-file-content" value={content} disabled={disabled} onChange={event => setContent(event.target.value)} rows={18} spellCheck={false} className="font-mono text-sm" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" disabled={disabled || !dirty} onClick={async () => { if (await onSave(content)) setSaved(content) }}>{t('authoring.files.save')}</Button>
|
||||
{dirty && <span role="status" className="text-sm text-muted-foreground">{t('authoring.files.unsaved')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
14
web/src/features/authoring/query-error.tsx
Normal file
14
web/src/features/authoring/query-error.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { ApiError } from '@/shared/lib/api-error'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
export function QueryError({ error, retry }: { error: unknown, retry: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const forbidden = error instanceof ApiError && (error.status === 401 || error.status === 403)
|
||||
return (
|
||||
<div role="alert" className="space-y-2 text-sm text-destructive">
|
||||
<p>{t(forbidden ? 'apiError.forbidden' : 'authoring.ui.loadFailed')}</p>
|
||||
<Button variant="outline" size="sm" onClick={retry}>{t('authoring.ui.retry')}</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
web/src/features/authoring/validation-results.test.tsx
Normal file
48
web/src/features/authoring/validation-results.test.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { authoringApi } from '@/api/client'
|
||||
import i18n from '@/i18n/config'
|
||||
import { ValidationResults } from './validation-results'
|
||||
vi.mock('@/api/client', () => ({ authoringApi: { getRun: vi.fn(), listFindings: vi.fn(), cancelRun: vi.fn() } }))
|
||||
vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
const clients: QueryClient[] = []
|
||||
function mount() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
clients.push(client)
|
||||
render(<QueryClientProvider client={client}><ValidationResults draftId={42} runId={8} /></QueryClientProvider>)
|
||||
return client
|
||||
}
|
||||
beforeEach(async () => { vi.clearAllMocks(); await i18n.changeLanguage('en') })
|
||||
afterEach(() => { cleanup(); clients.forEach(client => client.clear()); clients.length = 0 })
|
||||
describe('structure validation results', () => {
|
||||
it('loads final findings and refreshes draft validity on completion', async () => {
|
||||
vi.mocked(authoringApi.getRun).mockResolvedValue({ id: 8, draftId: 42, status: 'RUNNING', active: true, terminal: false })
|
||||
vi.mocked(authoringApi.listFindings).mockResolvedValueOnce([]).mockResolvedValue([{ id: 1, severity: 'ERROR', ruleCode: 'FRONTMATTER_FIELD_MISSING', filePath: 'SKILL.md', location: 'frontmatter', message: 'Missing description' }])
|
||||
const client = mount()
|
||||
client.setQueryData(['authoring', 'draft', 42], { validated: false })
|
||||
await screen.findByText('Validation is in progress. Findings will appear here.')
|
||||
client.setQueryData(['authoring', 'run', 8], { id: 8, draftId: 42, status: 'FAILED', terminal: true, active: false, errorCount: 1 })
|
||||
await screen.findByText('Missing description')
|
||||
expect(screen.getByText('SKILL.md · frontmatter')).toBeTruthy()
|
||||
expect(client.getQueryState(['authoring', 'draft', 42])?.isInvalidated).toBe(true)
|
||||
expect(screen.queryByRole('button', { name: 'Cancel run' })).toBeNull()
|
||||
})
|
||||
it('cancels an active run and shows its terminal state', async () => {
|
||||
vi.mocked(authoringApi.getRun).mockResolvedValue({ id: 8, draftId: 42, status: 'RUNNING', active: true })
|
||||
vi.mocked(authoringApi.listFindings).mockResolvedValue([])
|
||||
vi.mocked(authoringApi.cancelRun).mockResolvedValue({ id: 8, draftId: 42, status: 'CANCELLED', terminal: true, active: false })
|
||||
mount()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Cancel run' }))
|
||||
await waitFor(() => expect(authoringApi.cancelRun).toHaveBeenCalledWith(8))
|
||||
await screen.findByText(/Cancelled/)
|
||||
expect(screen.queryByText(/passed validation/)).toBeNull()
|
||||
})
|
||||
it('does not fetch findings for a run belonging to another draft', async () => {
|
||||
vi.mocked(authoringApi.getRun).mockResolvedValue({ id: 8, draftId: 99, status: 'SUCCEEDED', terminal: true })
|
||||
mount()
|
||||
await screen.findByRole('alert')
|
||||
expect(authoringApi.listFindings).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
62
web/src/features/authoring/validation-results.tsx
Normal file
62
web/src/features/authoring/validation-results.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { authoringApi } from '@/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent } from '@/shared/ui/card'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { QueryError } from './query-error'
|
||||
|
||||
export function ValidationResults({ runId, draftId }: { runId: number, draftId: number }) {
|
||||
const { t } = useTranslation()
|
||||
const client = useQueryClient()
|
||||
const runKey = ['authoring', 'run', runId]
|
||||
const run = useQuery({
|
||||
queryKey: runKey, queryFn: () => authoringApi.getRun(runId),
|
||||
refetchInterval: query => query.state.data?.terminal ? false : 1500,
|
||||
meta: { skipGlobalErrorHandler: true }, retry: false,
|
||||
})
|
||||
const findings = useQuery({
|
||||
queryKey: [...runKey, 'findings', run.data?.status], queryFn: () => authoringApi.listFindings(runId),
|
||||
enabled: run.isSuccess && run.data.draftId === draftId,
|
||||
refetchInterval: run.data?.terminal ? false : 1500,
|
||||
meta: { skipGlobalErrorHandler: true }, retry: false,
|
||||
})
|
||||
useEffect(() => {
|
||||
if (run.data?.terminal && run.data.draftId === draftId) {
|
||||
void client.invalidateQueries({ queryKey: ['authoring', 'draft', draftId] })
|
||||
void client.invalidateQueries({ queryKey: ['authoring', 'drafts'] })
|
||||
}
|
||||
}, [client, draftId, run.data?.terminal, run.data?.draftId])
|
||||
const cancel = useMutation({
|
||||
mutationFn: () => authoringApi.cancelRun(runId),
|
||||
onSuccess: data => client.setQueryData(runKey, data),
|
||||
onError: error => toast.error(error instanceof Error ? error.message : t('apiError.unknown')),
|
||||
})
|
||||
if (run.isPending) return <p role="status">{t('authoring.ui.loading')}</p>
|
||||
if (run.isError) return <QueryError error={run.error} retry={() => void run.refetch()} />
|
||||
if (run.data.draftId !== draftId) return <p role="alert">{t('authoring.detail.loadFailed')}</p>
|
||||
const data = run.data
|
||||
return (
|
||||
<Card><CardContent className="space-y-4 pt-6" data-testid="validation-results">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="space-y-1" role="status">
|
||||
<p className="font-medium">{t(`authoring.run.status.${data.status}`, { defaultValue: data.status })} · {t('authoring.run.revision')} {data.draftRevision}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('authoring.run.errors', { count: data.errorCount ?? 0 })} · {t('authoring.run.warnings', { count: data.warningCount ?? 0 })}</p>
|
||||
</div>
|
||||
{data.active && <Button variant="outline" disabled={cancel.isPending || data.cancelRequested} onClick={() => cancel.mutate()}>{t('authoring.run.cancel')}</Button>}
|
||||
</div>
|
||||
{findings.isPending && <p>{t('authoring.ui.loading')}</p>}
|
||||
{findings.isError && <QueryError error={findings.error} retry={() => void findings.refetch()} />}
|
||||
{findings.isSuccess && findings.data.length === 0 && <p className="text-sm text-muted-foreground">{t(data.terminal ? 'authoring.run.noFindings' : 'authoring.run.waiting')}</p>}
|
||||
<ul className="space-y-3">
|
||||
{findings.data?.map((finding, index) => <li key={finding.id ?? index} className="space-y-1 rounded-md border p-3">
|
||||
<p className="break-words text-sm font-medium">{finding.severity === 'ERROR' ? t('authoring.run.error') : t('authoring.run.warning')} · {finding.ruleCode}</p>
|
||||
<p className="break-words text-sm">{finding.message}</p>
|
||||
{finding.filePath && <p className="break-all font-mono text-xs text-muted-foreground">{finding.filePath}{finding.location ? ` · ${finding.location}` : ''}</p>}
|
||||
{finding.suggestion?.description && <p className="break-words text-sm text-muted-foreground">{finding.suggestion.description}</p>}
|
||||
</li>)}
|
||||
</ul>
|
||||
</CardContent></Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -125,3 +125,25 @@ describe('clearSessionScopedQueries', () => {
|
|||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('authoring session isolation', () => {
|
||||
it('drops private draft caches and ignores a late response after the viewer changes', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
let finishPrevious: (value: string[]) => void = () => {}
|
||||
const previous = new Promise<string[]>(resolve => { finishPrevious = resolve })
|
||||
const loader = vi.fn().mockReturnValueOnce(previous).mockResolvedValue(['viewer-b-draft'])
|
||||
client.setQueryData(['authoring', 'draft', 42, 'content', 'SKILL.md'], { content: 'viewer-a-private' })
|
||||
const observer = new QueryObserver(client, { queryKey: ['authoring', 'drafts'], queryFn: loader })
|
||||
const unsubscribe = observer.subscribe(() => {})
|
||||
try {
|
||||
clearSessionScopedQueries(client)
|
||||
expect(client.getQueryData(['authoring', 'draft', 42, 'content', 'SKILL.md'])).toBeUndefined()
|
||||
await vi.waitFor(() => expect(observer.getCurrentResult().data).toEqual(['viewer-b-draft']))
|
||||
finishPrevious(['viewer-a-draft'])
|
||||
await previous
|
||||
await Promise.resolve()
|
||||
expect(observer.getCurrentResult().data).toEqual(['viewer-b-draft'])
|
||||
} finally { unsubscribe(); client.clear() }
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export function getNotificationQueryKeyScope(userId?: string | null) {
|
|||
}
|
||||
|
||||
export function clearSessionScopedQueries(queryClient: QueryClient) {
|
||||
void queryClient.resetQueries({ queryKey: ['authoring'] })
|
||||
queryClient.removeQueries({ queryKey: ['notifications'] })
|
||||
queryClient.removeQueries({ queryKey: ['labels'] })
|
||||
queryClient.removeQueries({ queryKey: ['skills', 'my'] })
|
||||
|
|
|
|||
|
|
@ -496,6 +496,7 @@
|
|||
"userId": "User ID"
|
||||
},
|
||||
"sidebar": {
|
||||
"authoring": "Skill authoring",
|
||||
"account": "Account",
|
||||
"profile": "Profile",
|
||||
"security": "Security",
|
||||
|
|
@ -3299,5 +3300,95 @@
|
|||
"title": "Subscribed skill version yanked",
|
||||
"description": "{{skillName}}{{versionSuffix}} version was yanked."
|
||||
}
|
||||
},
|
||||
"authoring": {
|
||||
"ui": {
|
||||
"loading": "Loading…",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"create": "Create",
|
||||
"submitting": "Submitting…",
|
||||
"loadFailed": "Unable to load this resource.",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"list": {
|
||||
"title": "Skill authoring",
|
||||
"subtitle": "Create and edit private drafts, then check their package structure.",
|
||||
"create": "New draft",
|
||||
"empty": "No drafts yet. Create one to start authoring a skill.",
|
||||
"colName": "Name",
|
||||
"colRevision": "Revision",
|
||||
"colValidated": "Validation",
|
||||
"colUpdated": "Updated",
|
||||
"colActions": "Actions",
|
||||
"validated": "Structure checked",
|
||||
"notValidated": "Not validated",
|
||||
"deleteTitle": "Delete draft",
|
||||
"deleteDescription": "Delete draft “{{name}}” and all of its files? This cannot be undone.",
|
||||
"created": "Draft created",
|
||||
"deleted": "Draft deleted",
|
||||
"createTitle": "Create a skill draft",
|
||||
"createDescription": "Starts from a SKILL.md scaffold you can edit and validate.",
|
||||
"fieldNamespace": "Namespace",
|
||||
"namespacePlaceholder": "Select a namespace",
|
||||
"fieldName": "Skill name",
|
||||
"fieldRequirement": "Requirement (optional)",
|
||||
"requirementPlaceholder": "What should this skill do?"
|
||||
},
|
||||
"detail": {
|
||||
"back": "All drafts",
|
||||
"validate": "Check structure",
|
||||
"starting": "Starting…",
|
||||
"tabRuns": "Validation runs",
|
||||
"noRuns": "No validation runs yet. Start one to check the draft.",
|
||||
"loadFailed": "Failed to load the draft.",
|
||||
"structureOnly": "This checks the skill package structure. It does not execute scripts, assess behavior or publish the skill.",
|
||||
"saveFirst": "Save or discard file changes before starting validation."
|
||||
},
|
||||
"files": {
|
||||
"save": "Save",
|
||||
"saved": "File saved",
|
||||
"create": "Create file",
|
||||
"created": "File created",
|
||||
"exists": "A file already exists at this path. Choose another path.",
|
||||
"delete": "Delete",
|
||||
"deleted": "File deleted",
|
||||
"empty": "No files yet.",
|
||||
"unsaved": "Unsaved changes",
|
||||
"upload": "Upload file",
|
||||
"uploadConfirm": "Upload",
|
||||
"uploading": "Uploading…",
|
||||
"uploaded": "File uploaded",
|
||||
"path": "Path",
|
||||
"size": "Size",
|
||||
"binaryHint": "This is a binary file and cannot be edited as text. Upload a new file to replace it.",
|
||||
"replaceHint": "Uploading to an existing path replaces that resource.",
|
||||
"discardTitle": "Discard unsaved changes?",
|
||||
"discardDescription": "Your unsaved edits will be lost.",
|
||||
"deleteTitle": "Delete file?",
|
||||
"deleteDescription": "Delete {{path}} from this draft? Unsaved edits to this file will be lost.",
|
||||
"content": "File content",
|
||||
"title": "Draft files",
|
||||
"reload": "Reload file"
|
||||
},
|
||||
"run": {
|
||||
"revision": "revision",
|
||||
"errors": "{{count}} error(s)",
|
||||
"warnings": "{{count}} warning(s)",
|
||||
"cancel": "Cancel run",
|
||||
"noFindings": "No findings recorded for this run.",
|
||||
"waiting": "Validation is in progress. Findings will appear here.",
|
||||
"error": "Error",
|
||||
"warning": "Warning",
|
||||
"status": {
|
||||
"QUEUED": "Queued",
|
||||
"PREPARING": "Preparing",
|
||||
"RUNNING": "Running",
|
||||
"SUCCEEDED": "Succeeded",
|
||||
"FAILED": "Failed",
|
||||
"CANCELLED": "Cancelled",
|
||||
"TIMED_OUT": "Timed out"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -533,6 +533,7 @@
|
|||
"userId": "사용자 ID"
|
||||
},
|
||||
"sidebar": {
|
||||
"authoring": "스킬 작성",
|
||||
"account": "계정",
|
||||
"profile": "프로필",
|
||||
"security": "보안",
|
||||
|
|
@ -3580,5 +3581,95 @@
|
|||
"title": "구독한 스킬 버전 배포 철회",
|
||||
"description": "{{skillName}}{{versionSuffix}} 버전의 배포가 철회되었습니다."
|
||||
}
|
||||
},
|
||||
"authoring": {
|
||||
"ui": {
|
||||
"loading": "불러오는 중…",
|
||||
"cancel": "취소",
|
||||
"delete": "삭제",
|
||||
"create": "만들기",
|
||||
"submitting": "처리 중…",
|
||||
"loadFailed": "데이터를 불러올 수 없습니다.",
|
||||
"retry": "다시 시도"
|
||||
},
|
||||
"list": {
|
||||
"title": "스킬 작성",
|
||||
"subtitle": "비공개 초안을 만들고 편집한 후 패키지 구조를 검사합니다.",
|
||||
"create": "새 초안",
|
||||
"empty": "아직 초안이 없습니다.",
|
||||
"colName": "이름",
|
||||
"colRevision": "수정 버전",
|
||||
"colValidated": "검증",
|
||||
"colUpdated": "수정 시각",
|
||||
"colActions": "작업",
|
||||
"validated": "구조 검증 완료",
|
||||
"notValidated": "검증되지 않음",
|
||||
"deleteTitle": "초안 삭제",
|
||||
"deleteDescription": "초안 “{{name}}”과 모든 파일을 삭제하시겠습니까? 되돌릴 수 없습니다.",
|
||||
"created": "초안을 만들었습니다.",
|
||||
"deleted": "초안을 삭제했습니다.",
|
||||
"createTitle": "스킬 초안 만들기",
|
||||
"createDescription": "편집하고 검증할 수 있는 SKILL.md 템플릿으로 시작합니다.",
|
||||
"fieldNamespace": "네임스페이스",
|
||||
"namespacePlaceholder": "네임스페이스 선택",
|
||||
"fieldName": "스킬 이름",
|
||||
"fieldRequirement": "요구 사항 (선택 사항)",
|
||||
"requirementPlaceholder": "이 스킬은 어떤 작업을 해야 하나요?"
|
||||
},
|
||||
"detail": {
|
||||
"back": "모든 초안",
|
||||
"validate": "구조 검사",
|
||||
"starting": "시작 중…",
|
||||
"tabRuns": "검증 실행 기록",
|
||||
"noRuns": "아직 검증 기록이 없습니다.",
|
||||
"loadFailed": "초안을 불러올 수 없습니다.",
|
||||
"structureOnly": "패키지 구조만 검사합니다. 스크립트를 실행하거나 동작을 평가하거나 스킬을 게시하지 않습니다.",
|
||||
"saveFirst": "검증 전에 변경 사항을 저장하거나 취소하세요."
|
||||
},
|
||||
"files": {
|
||||
"save": "저장",
|
||||
"saved": "파일을 저장했습니다.",
|
||||
"create": "파일 만들기",
|
||||
"created": "파일을 만들었습니다.",
|
||||
"exists": "같은 경로에 파일이 있습니다. 다른 경로를 선택하세요.",
|
||||
"delete": "삭제",
|
||||
"deleted": "파일을 삭제했습니다.",
|
||||
"empty": "파일이 없습니다.",
|
||||
"unsaved": "저장되지 않은 변경 사항",
|
||||
"upload": "파일 업로드",
|
||||
"uploadConfirm": "업로드",
|
||||
"uploading": "업로드 중…",
|
||||
"uploaded": "파일을 업로드했습니다.",
|
||||
"path": "경로",
|
||||
"size": "크기",
|
||||
"binaryHint": "바이너리 파일은 텍스트로 편집할 수 없습니다. 새 파일을 업로드하여 교체하세요.",
|
||||
"replaceHint": "기존 경로에 업로드하면 해당 파일이 교체됩니다.",
|
||||
"discardTitle": "저장하지 않은 변경 사항을 취소할까요?",
|
||||
"discardDescription": "저장하지 않은 변경 사항이 사라집니다.",
|
||||
"deleteTitle": "파일을 삭제할까요?",
|
||||
"deleteDescription": "초안에서 {{path}} 파일을 삭제할까요? 저장하지 않은 변경 사항도 사라집니다.",
|
||||
"content": "파일 내용",
|
||||
"title": "초안 파일",
|
||||
"reload": "파일 다시 불러오기"
|
||||
},
|
||||
"run": {
|
||||
"revision": "수정 버전",
|
||||
"errors": "오류 {{count}}개",
|
||||
"warnings": "경고 {{count}}개",
|
||||
"cancel": "실행 취소",
|
||||
"noFindings": "기록된 문제가 없습니다.",
|
||||
"waiting": "검증 중입니다. 결과가 여기에 표시됩니다.",
|
||||
"error": "오류",
|
||||
"warning": "경고",
|
||||
"status": {
|
||||
"QUEUED": "대기 중",
|
||||
"PREPARING": "준비 중",
|
||||
"RUNNING": "실행 중",
|
||||
"SUCCEEDED": "성공",
|
||||
"FAILED": "실패",
|
||||
"CANCELLED": "취소됨",
|
||||
"TIMED_OUT": "시간 초과"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -496,6 +496,7 @@
|
|||
"userId": "ID пользователя"
|
||||
},
|
||||
"sidebar": {
|
||||
"authoring": "Создание навыков",
|
||||
"account": "Аккаунт",
|
||||
"profile": "Профиль",
|
||||
"security": "Безопасность",
|
||||
|
|
@ -3301,5 +3302,95 @@
|
|||
"title": "Версия подписанного навыка отозвана",
|
||||
"description": "Версия {{skillName}}{{versionSuffix}} отозвана."
|
||||
}
|
||||
},
|
||||
"authoring": {
|
||||
"ui": {
|
||||
"loading": "Загрузка…",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить",
|
||||
"create": "Создать",
|
||||
"submitting": "Отправка…",
|
||||
"loadFailed": "Не удалось загрузить данные.",
|
||||
"retry": "Повторить"
|
||||
},
|
||||
"list": {
|
||||
"title": "Создание Skill",
|
||||
"subtitle": "Создавайте частные черновики и проверяйте структуру пакетов.",
|
||||
"create": "Новый черновик",
|
||||
"empty": "Черновиков пока нет. Создайте первый.",
|
||||
"colName": "Название",
|
||||
"colRevision": "Ревизия",
|
||||
"colValidated": "Валидация",
|
||||
"colUpdated": "Обновлён",
|
||||
"colActions": "Действия",
|
||||
"validated": "Структура проверена",
|
||||
"notValidated": "Не проверен",
|
||||
"deleteTitle": "Удалить черновик",
|
||||
"deleteDescription": "Удалить черновик «{{name}}» и все его файлы? Действие необратимо.",
|
||||
"created": "Черновик создан",
|
||||
"deleted": "Черновик удалён",
|
||||
"createTitle": "Создать черновик Skill",
|
||||
"createDescription": "Начинается со скаффолда SKILL.md, который можно редактировать и валидировать.",
|
||||
"fieldNamespace": "Пространство имён",
|
||||
"namespacePlaceholder": "Выберите пространство имён",
|
||||
"fieldName": "Имя Skill",
|
||||
"fieldRequirement": "Требование (необязательно)",
|
||||
"requirementPlaceholder": "Что должен делать этот Skill?"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Все черновики",
|
||||
"validate": "Проверить структуру",
|
||||
"starting": "Запуск…",
|
||||
"tabRuns": "Прогоны валидации",
|
||||
"noRuns": "Прогонов ещё нет. Запустите валидацию.",
|
||||
"loadFailed": "Не удалось загрузить черновик.",
|
||||
"structureOnly": "Проверяется только структура пакета. Скрипты не запускаются, поведение не оценивается, навык не публикуется.",
|
||||
"saveFirst": "Сохраните или отмените изменения перед проверкой."
|
||||
},
|
||||
"files": {
|
||||
"save": "Сохранить",
|
||||
"saved": "Файл сохранён",
|
||||
"create": "Создать файл",
|
||||
"created": "Файл создан",
|
||||
"exists": "По этому пути уже есть файл. Выберите другой путь.",
|
||||
"delete": "Удалить",
|
||||
"deleted": "Файл удалён",
|
||||
"empty": "Файлов пока нет.",
|
||||
"unsaved": "Несохранённые изменения",
|
||||
"upload": "Загрузить файл",
|
||||
"uploadConfirm": "Загрузить",
|
||||
"uploading": "Загрузка…",
|
||||
"uploaded": "Файл загружен",
|
||||
"path": "Путь",
|
||||
"size": "Размер",
|
||||
"binaryHint": "Это двоичный файл, его нельзя редактировать как текст. Загрузите новый файл для замены.",
|
||||
"replaceHint": "Загрузка по существующему пути заменит файл.",
|
||||
"discardTitle": "Отменить несохранённые изменения?",
|
||||
"discardDescription": "Несохранённые изменения будут потеряны.",
|
||||
"deleteTitle": "Удалить файл?",
|
||||
"deleteDescription": "Удалить {{path}} из черновика? Несохранённые изменения файла будут потеряны.",
|
||||
"content": "Содержимое файла",
|
||||
"title": "Файлы черновика",
|
||||
"reload": "Перезагрузить файл"
|
||||
},
|
||||
"run": {
|
||||
"revision": "ревизия",
|
||||
"errors": "ошибок: {{count}}",
|
||||
"warnings": "предупреждений: {{count}}",
|
||||
"cancel": "Отменить прогон",
|
||||
"noFindings": "Для этого запуска замечаний нет.",
|
||||
"waiting": "Проверка выполняется. Результаты появятся здесь.",
|
||||
"error": "Ошибка",
|
||||
"warning": "Предупреждение",
|
||||
"status": {
|
||||
"QUEUED": "В очереди",
|
||||
"PREPARING": "Подготовка",
|
||||
"RUNNING": "Выполняется",
|
||||
"SUCCEEDED": "Успешно",
|
||||
"FAILED": "Ошибка",
|
||||
"CANCELLED": "Отменено",
|
||||
"TIMED_OUT": "Время истекло"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -496,6 +496,7 @@
|
|||
"userId": "用户 ID"
|
||||
},
|
||||
"sidebar": {
|
||||
"authoring": "技能创作",
|
||||
"account": "账号管理",
|
||||
"profile": "个人设置",
|
||||
"security": "安全设置",
|
||||
|
|
@ -3298,5 +3299,95 @@
|
|||
"title": "订阅技能版本撤回",
|
||||
"description": "{{skillName}}{{versionSuffix}} 版本已撤回。"
|
||||
}
|
||||
},
|
||||
"authoring": {
|
||||
"ui": {
|
||||
"loading": "加载中…",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"create": "创建",
|
||||
"submitting": "提交中…",
|
||||
"loadFailed": "加载失败,请重试。",
|
||||
"retry": "重试"
|
||||
},
|
||||
"list": {
|
||||
"title": "Skill 创作工作台",
|
||||
"subtitle": "创建、编辑私有草稿,并检查技能包结构。",
|
||||
"create": "新建草稿",
|
||||
"empty": "还没有草稿。创建一个开始编写 Skill。",
|
||||
"colName": "名称",
|
||||
"colRevision": "版本",
|
||||
"colValidated": "验证状态",
|
||||
"colUpdated": "更新时间",
|
||||
"colActions": "操作",
|
||||
"validated": "结构已验证",
|
||||
"notValidated": "未验证",
|
||||
"deleteTitle": "删除草稿",
|
||||
"deleteDescription": "删除草稿“{{name}}”及其所有文件?该操作不可撤销。",
|
||||
"created": "草稿已创建",
|
||||
"deleted": "草稿已删除",
|
||||
"createTitle": "创建 Skill 草稿",
|
||||
"createDescription": "以 SKILL.md 脚手架为起点,可编辑并验证。",
|
||||
"fieldNamespace": "命名空间",
|
||||
"namespacePlaceholder": "选择命名空间",
|
||||
"fieldName": "Skill 名称",
|
||||
"fieldRequirement": "需求(可选)",
|
||||
"requirementPlaceholder": "这个 Skill 应该做什么?"
|
||||
},
|
||||
"detail": {
|
||||
"back": "返回草稿列表",
|
||||
"validate": "验证结构",
|
||||
"starting": "启动中…",
|
||||
"tabRuns": "验证记录",
|
||||
"noRuns": "还没有验证记录。先运行一次验证。",
|
||||
"loadFailed": "草稿加载失败。",
|
||||
"structureOnly": "此处仅检查技能包结构,不执行脚本、不评估行为,也不发布技能。",
|
||||
"saveFirst": "请先保存或放弃文件修改,再开始验证。"
|
||||
},
|
||||
"files": {
|
||||
"save": "保存",
|
||||
"saved": "文件已保存",
|
||||
"create": "新建文件",
|
||||
"created": "文件已创建",
|
||||
"exists": "该路径已有文件,请使用其他路径。",
|
||||
"delete": "删除",
|
||||
"deleted": "文件已删除",
|
||||
"empty": "暂无文件。",
|
||||
"unsaved": "有未保存的修改",
|
||||
"upload": "上传文件",
|
||||
"uploadConfirm": "上传",
|
||||
"uploading": "上传中…",
|
||||
"uploaded": "文件已上传",
|
||||
"path": "路径",
|
||||
"size": "大小",
|
||||
"binaryHint": "这是二进制文件,无法作为文本编辑。可上传新文件替换。",
|
||||
"replaceHint": "上传到已有路径会替换对应文件。",
|
||||
"discardTitle": "放弃未保存的修改?",
|
||||
"discardDescription": "未保存的编辑内容将丢失。",
|
||||
"deleteTitle": "删除文件?",
|
||||
"deleteDescription": "确认从草稿删除 {{path}}?该文件未保存的修改也将丢失。",
|
||||
"content": "文件内容",
|
||||
"title": "草稿文件",
|
||||
"reload": "重新加载文件"
|
||||
},
|
||||
"run": {
|
||||
"revision": "版本",
|
||||
"errors": "{{count}} 个错误",
|
||||
"warnings": "{{count}} 个警告",
|
||||
"cancel": "取消运行",
|
||||
"noFindings": "本次运行没有记录问题。",
|
||||
"waiting": "验证进行中,发现的问题将在此显示。",
|
||||
"error": "错误",
|
||||
"warning": "警告",
|
||||
"status": {
|
||||
"QUEUED": "排队中",
|
||||
"PREPARING": "准备中",
|
||||
"RUNNING": "运行中",
|
||||
"SUCCEEDED": "通过",
|
||||
"FAILED": "失败",
|
||||
"CANCELLED": "已取消",
|
||||
"TIMED_OUT": "已超时"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
102
web/src/pages/authoring/draft-detail.tsx
Normal file
102
web/src/pages/authoring/draft-detail.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowLeft, Play } from 'lucide-react'
|
||||
import type { ValidationRunInfo } from '@/api/types'
|
||||
import { authoringApi } from '@/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent } from '@/shared/ui/card'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { DraftFileEditor } from '@/features/authoring/draft-file-editor'
|
||||
import { QueryError } from '@/features/authoring/query-error'
|
||||
import { ValidationResults } from '@/features/authoring/validation-results'
|
||||
|
||||
export function DraftDetailPage() {
|
||||
const { draftId } = useParams({ from: '/dashboard/authoring/$draftId' })
|
||||
const id = Number(draftId)
|
||||
// Resource changes reset local editor/run selection along with query identity.
|
||||
return <DraftWorkbench key={draftId} id={id} />
|
||||
}
|
||||
|
||||
function DraftWorkbench({ id }: { id: number }) {
|
||||
const { t } = useTranslation()
|
||||
const client = useQueryClient()
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [selectedRunId, setSelectedRunId] = useState<number | null>(null)
|
||||
const validId = Number.isSafeInteger(id) && id > 0
|
||||
const draft = useQuery({
|
||||
queryKey: ['authoring', 'draft', id], queryFn: () => authoringApi.getDraft(id),
|
||||
enabled: validId, meta: { skipGlobalErrorHandler: true }, retry: false,
|
||||
})
|
||||
const files = useQuery({
|
||||
queryKey: ['authoring', 'draft', id, 'files'], queryFn: () => authoringApi.listFiles(id),
|
||||
enabled: validId && draft.data != null, meta: { skipGlobalErrorHandler: true }, retry: false,
|
||||
})
|
||||
const runs = useQuery({
|
||||
queryKey: ['authoring', 'draft', id, 'runs'], queryFn: () => authoringApi.listRuns(id),
|
||||
enabled: validId && draft.data != null, meta: { skipGlobalErrorHandler: true }, retry: false,
|
||||
refetchInterval: query => query.state.data?.some(run => run.active) ? 1500 : false,
|
||||
})
|
||||
const activeRun = runs.data?.some(run => run.active) ?? false
|
||||
const refreshDraft = () => {
|
||||
void client.invalidateQueries({ queryKey: ['authoring', 'draft', id] })
|
||||
void client.invalidateQueries({ queryKey: ['authoring', 'drafts'] })
|
||||
}
|
||||
const startRun = useMutation({
|
||||
mutationFn: () => authoringApi.startRun(id),
|
||||
onSuccess: run => {
|
||||
setSelectedRunId(run.id ?? null)
|
||||
client.setQueryData<ValidationRunInfo[]>(['authoring', 'draft', id, 'runs'], old => [run, ...(old ?? []).filter(item => item.id !== run.id)])
|
||||
refreshDraft()
|
||||
},
|
||||
onError: error => toast.error(error instanceof Error ? error.message : t('apiError.unknown')),
|
||||
})
|
||||
// Keep an active run visible even when the page is refreshed or opened mid-run.
|
||||
const selected = selectedRunId ?? runs.data?.find(run => run.active)?.id ?? runs.data?.[0]?.id
|
||||
useEffect(() => {
|
||||
if (activeRun) void client.invalidateQueries({ queryKey: ['authoring', 'draft', id] })
|
||||
}, [activeRun, client, id])
|
||||
|
||||
if (!validId) return <p role="alert">{t('authoring.detail.loadFailed')}</p>
|
||||
if (draft.isPending) return <p role="status">{t('authoring.ui.loading')}</p>
|
||||
if (!draft.data) return <QueryError error={draft.error} retry={() => void draft.refetch()} />
|
||||
const current = draft.data
|
||||
if (current.revision == null) return <p role="alert">{t('authoring.detail.loadFailed')}</p>
|
||||
return (
|
||||
<div className="min-w-0 space-y-6">
|
||||
<Link to="/dashboard/authoring" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden />{t('authoring.detail.back')}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<h1 className="break-all text-2xl font-semibold">{current.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('authoring.run.revision')} {current.revision} · {t(current.validated ? 'authoring.list.validated' : 'authoring.list.notValidated')}</p>
|
||||
</div>
|
||||
<Button onClick={() => startRun.mutate()} disabled={startRun.isPending || activeRun || dirty || files.isPending || files.isError} data-testid="start-run">
|
||||
<Play className="mr-1 h-4 w-4" aria-hidden />{t(startRun.isPending ? 'authoring.detail.starting' : 'authoring.detail.validate')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('authoring.detail.structureOnly')}</p>
|
||||
{dirty && <p role="status" className="text-sm text-muted-foreground">{t('authoring.detail.saveFirst')}</p>}
|
||||
{draft.isError && <QueryError error={draft.error} retry={() => void draft.refetch()} />}
|
||||
<Card><CardContent className="pt-6">
|
||||
{files.isPending && <p role="status">{t('authoring.ui.loading')}</p>}
|
||||
{files.isError && <QueryError error={files.error} retry={() => void files.refetch()} />}
|
||||
{files.data && <DraftFileEditor draftId={id} files={files.data} revision={current.revision} onSaved={refreshDraft} onDirtyChange={setDirty} />}
|
||||
</CardContent></Card>
|
||||
<section className="space-y-3" aria-label={t('authoring.detail.tabRuns')}>
|
||||
<h2 className="text-lg font-semibold">{t('authoring.detail.tabRuns')}</h2>
|
||||
{runs.isPending && <p role="status">{t('authoring.ui.loading')}</p>}
|
||||
{runs.isError && <QueryError error={runs.error} retry={() => void runs.refetch()} />}
|
||||
{runs.isSuccess && runs.data.length === 0 && <p>{t('authoring.detail.noRuns')}</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{runs.data?.map(run => run.id != null && <Button key={run.id} variant={selected === run.id ? 'default' : 'outline'} size="sm" onClick={() => setSelectedRunId(run.id ?? null)} aria-pressed={selected === run.id}>
|
||||
#{run.id} · {t(`authoring.run.status.${run.status}`, { defaultValue: run.status })}
|
||||
</Button>)}
|
||||
</div>
|
||||
{selected != null && <ValidationResults key={selected} runId={selected} draftId={id} />}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
288
web/src/pages/authoring/drafts.tsx
Normal file
288
web/src/pages/authoring/drafts.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import { useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { authoringApi, namespaceApi } from '@/api/client'
|
||||
import type { AuthoringDraft } from '@/api/types'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent } from '@/shared/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/shared/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/shared/ui/table'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { QueryError } from '@/features/authoring/query-error'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
|
||||
/**
|
||||
* Authoring workbench entry: the user's skill drafts with validation state and
|
||||
* quick access to the editor, plus draft creation.
|
||||
*/
|
||||
export function DraftsPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<AuthoringDraft | null>(null)
|
||||
|
||||
const drafts = useQuery({
|
||||
queryKey: ['authoring', 'drafts'],
|
||||
queryFn: () => authoringApi.listDrafts(),
|
||||
meta: { skipGlobalErrorHandler: true }, retry: false,
|
||||
})
|
||||
|
||||
const createDraft = useMutation({
|
||||
mutationFn: (request: { namespaceSlug: string, name: string, requirement?: string }) =>
|
||||
authoringApi.createDraft(request),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['authoring', 'drafts'] })
|
||||
setCreateOpen(false)
|
||||
toast.success(t('authoring.list.created'))
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : String(error))
|
||||
},
|
||||
})
|
||||
|
||||
const deleteDraft = useMutation({
|
||||
mutationFn: (draftId: number) => authoringApi.deleteDraft(draftId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['authoring', 'drafts'] })
|
||||
setDeleteTarget(null)
|
||||
toast.success(t('authoring.list.deleted'))
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : String(error))
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<DashboardPageHeader
|
||||
title={t('authoring.list.title')}
|
||||
subtitle={t('authoring.list.subtitle')}
|
||||
actions={
|
||||
<Button onClick={() => setCreateOpen(true)} data-testid="create-draft">
|
||||
<Plus className="mr-1 h-4 w-4" aria-hidden />
|
||||
{t('authoring.list.create')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{drafts.isLoading && <p className="text-sm text-muted-foreground">{t('authoring.ui.loading')}</p>}
|
||||
{drafts.isError && <QueryError error={drafts.error} retry={() => void drafts.refetch()} />}
|
||||
{drafts.data && drafts.data.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('authoring.list.empty')}</p>
|
||||
)}
|
||||
{drafts.data && drafts.data.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('authoring.list.colName')}</TableHead>
|
||||
<TableHead>{t('authoring.list.colRevision')}</TableHead>
|
||||
<TableHead>{t('authoring.list.colValidated')}</TableHead>
|
||||
<TableHead>{t('authoring.list.colUpdated')}</TableHead>
|
||||
<TableHead className="w-32">{t('authoring.list.colActions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{drafts.data.map((draft) => draft.id == null ? null : (
|
||||
<TableRow key={draft.id} data-testid={`draft-row-${draft.id}`}>
|
||||
<TableCell>
|
||||
<Link
|
||||
to="/dashboard/authoring/$draftId"
|
||||
params={{ draftId: String(draft.id) }}
|
||||
className="break-all font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{draft.name}
|
||||
</Link>
|
||||
{draft.requirement && (
|
||||
<p className="max-w-md truncate text-xs text-muted-foreground">{draft.requirement}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>#{draft.revision}</TableCell>
|
||||
<TableCell>
|
||||
{draft.validated ? (
|
||||
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-950 dark:text-green-300">
|
||||
{t('authoring.list.validated')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{t('authoring.list.notValidated')}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{draft.updatedAt ? formatLocalDateTime(draft.updatedAt, i18n.language) : '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDeleteTarget(draft)}
|
||||
data-testid={`delete-draft-${draft.id}`}
|
||||
>
|
||||
{t('authoring.ui.delete')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateDraftDialog
|
||||
key={String(createOpen)}
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
submitting={createDraft.isPending}
|
||||
onSubmit={(request) => createDraft.mutate(request)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
}}
|
||||
title={t('authoring.list.deleteTitle')}
|
||||
description={t('authoring.list.deleteDescription', { name: deleteTarget?.name ?? '' })}
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
if (deleteTarget?.id == null) return
|
||||
try { await deleteDraft.mutateAsync(deleteTarget.id) } catch { /* Keep the confirmation open on failure. */ }
|
||||
}}
|
||||
closeOnConfirm={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateDraftDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
submitting,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
submitting: boolean
|
||||
onSubmit: (request: { namespaceSlug: string, name: string, requirement?: string }) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState('')
|
||||
const [namespaceSlug, setNamespaceSlug] = useState('')
|
||||
const [requirement, setRequirement] = useState('')
|
||||
|
||||
const namespaces = useQuery({
|
||||
queryKey: ['authoring', 'namespaces'],
|
||||
queryFn: () => namespaceApi.listMine(),
|
||||
enabled: open,
|
||||
meta: { skipGlobalErrorHandler: true }, retry: false,
|
||||
})
|
||||
|
||||
const submit = () => {
|
||||
if (!namespaceSlug || !name.trim()) {
|
||||
return
|
||||
}
|
||||
onSubmit({ namespaceSlug, name: name.trim(), requirement: requirement.trim() || undefined })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (submitting) return
|
||||
if (!nextOpen) {
|
||||
setName('')
|
||||
setNamespaceSlug('')
|
||||
setRequirement('')
|
||||
}
|
||||
onOpenChange(nextOpen)
|
||||
}}
|
||||
>
|
||||
<DialogContent hideClose={submitting}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('authoring.list.createTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('authoring.list.createDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draft-namespace">{t('authoring.list.fieldNamespace')}</Label>
|
||||
{namespaces.isError && <QueryError error={namespaces.error} retry={() => void namespaces.refetch()} />}
|
||||
{namespaces.isLoading && <p role="status">{t('authoring.ui.loading')}</p>}
|
||||
<Select disabled={submitting || namespaces.isLoading} value={namespaceSlug} onValueChange={setNamespaceSlug}>
|
||||
<SelectTrigger id="draft-namespace" data-testid="draft-namespace">
|
||||
<SelectValue placeholder={t('authoring.list.namespacePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(namespaces.data ?? []).map((namespace) => (
|
||||
<SelectItem key={namespace.slug} value={namespace.slug}>
|
||||
{namespace.displayName || namespace.slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draft-name">{t('authoring.list.fieldName')}</Label>
|
||||
<Input
|
||||
disabled={submitting}
|
||||
id="draft-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="document-summarizer"
|
||||
data-testid="draft-name"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draft-requirement">{t('authoring.list.fieldRequirement')}</Label>
|
||||
<Textarea
|
||||
disabled={submitting}
|
||||
id="draft-requirement"
|
||||
rows={3}
|
||||
value={requirement}
|
||||
onChange={(event) => setRequirement(event.target.value)}
|
||||
placeholder={t('authoring.list.requirementPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={submitting} onClick={() => onOpenChange(false)}>
|
||||
{t('authoring.ui.cancel')}
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting || !namespaceSlug || !name.trim()} data-testid="submit-create-draft">
|
||||
{submitting ? t('authoring.ui.submitting') : t('authoring.ui.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style'
|
|||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import {
|
||||
Star, Heart, Package, Boxes, Key, Shield, Flag, Globe,
|
||||
UserCog, Lock, Bell, Clock, ChevronDown, ChevronRight, Github,
|
||||
UserCog, Lock, Bell, Clock, ChevronDown, ChevronRight, Github, PenTool,
|
||||
} from 'lucide-react'
|
||||
|
||||
/**
|
||||
|
|
@ -52,6 +52,7 @@ export const SIDEBAR_GROUPS: SidebarGroup[] = [
|
|||
{ key: 'stars', icon: Star, label: 'sidebar.stars', to: '/dashboard/stars', analyticsId: 'navigation.starred.open' },
|
||||
{ key: 'subscriptions', icon: Heart, label: 'sidebar.subscriptions', to: '/dashboard/subscriptions', analyticsId: 'navigation.subscriptions.open' },
|
||||
{ key: 'tokens', icon: Key, label: 'sidebar.tokens', to: '/dashboard/tokens', analyticsId: 'account.tokens.open' },
|
||||
{ key: 'authoring', icon: PenTool, label: 'sidebar.authoring', to: '/dashboard/authoring', analyticsId: 'navigation.authoring.open' },
|
||||
{ key: 'reviewProgress', icon: Clock, label: 'sidebar.reviewProgress', to: '/dashboard/review-progress', analyticsId: 'review.progress.open' },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue