feat(web): add oRPC contracts and service hooks for app asset API

- Add TypeScript types for app asset management (types/app-asset.ts)
- Add oRPC contract definitions with nested router pattern (contract/console/app-asset.ts)
- Add React Query hooks for all asset operations (service/use-app-asset.ts)
- Integrate app asset contracts into console router

Endpoints covered: tree, createFolder, createFile, getFileContent,
updateFileContent, deleteNode, renameNode, moveNode, reorderNode, publish
This commit is contained in:
yyh
2026-01-15 09:50:05 +08:00
parent 6cb8d03bf6
commit 18c7f4698a
4 changed files with 578 additions and 0 deletions

View File

@ -0,0 +1,119 @@
import type {
AppAssetDeleteResponse,
AppAssetFileContentResponse,
AppAssetNode,
AppAssetPublishResponse,
AppAssetTreeResponse,
CreateFolderPayload,
MoveNodePayload,
RenameNodePayload,
ReorderNodePayload,
UpdateFileContentPayload,
} from '@/types/app-asset'
import { type } from '@orpc/contract'
import { base } from '../base'
export const treeContract = base
.route({
path: '/apps/{appId}/assets/tree',
method: 'GET',
})
.input(type<{
params: { appId: string }
}>())
.output(type<AppAssetTreeResponse>())
export const createFolderContract = base
.route({
path: '/apps/{appId}/assets/folders',
method: 'POST',
})
.input(type<{
params: { appId: string }
body: CreateFolderPayload
}>())
.output(type<AppAssetNode>())
export const createFileContract = base
.route({
path: '/apps/{appId}/assets/files',
method: 'POST',
})
.input(type<{
params: { appId: string }
}>())
.output(type<AppAssetNode>())
export const getFileContentContract = base
.route({
path: '/apps/{appId}/assets/files/{nodeId}',
method: 'GET',
})
.input(type<{
params: { appId: string, nodeId: string }
}>())
.output(type<AppAssetFileContentResponse>())
export const updateFileContentContract = base
.route({
path: '/apps/{appId}/assets/files/{nodeId}',
method: 'PUT',
})
.input(type<{
params: { appId: string, nodeId: string }
body: UpdateFileContentPayload
}>())
.output(type<AppAssetNode>())
export const deleteNodeContract = base
.route({
path: '/apps/{appId}/assets/nodes/{nodeId}',
method: 'DELETE',
})
.input(type<{
params: { appId: string, nodeId: string }
}>())
.output(type<AppAssetDeleteResponse>())
export const renameNodeContract = base
.route({
path: '/apps/{appId}/assets/nodes/{nodeId}/rename',
method: 'POST',
})
.input(type<{
params: { appId: string, nodeId: string }
body: RenameNodePayload
}>())
.output(type<AppAssetNode>())
export const moveNodeContract = base
.route({
path: '/apps/{appId}/assets/nodes/{nodeId}/move',
method: 'POST',
})
.input(type<{
params: { appId: string, nodeId: string }
body: MoveNodePayload
}>())
.output(type<AppAssetNode>())
export const reorderNodeContract = base
.route({
path: '/apps/{appId}/assets/nodes/{nodeId}/reorder',
method: 'POST',
})
.input(type<{
params: { appId: string, nodeId: string }
body: ReorderNodePayload
}>())
.output(type<AppAssetNode>())
export const publishContract = base
.route({
path: '/apps/{appId}/assets/publish',
method: 'POST',
})
.input(type<{
params: { appId: string }
}>())
.output(type<AppAssetPublishResponse>())

View File

@ -4,6 +4,18 @@ import {
bindPartnerStackContract,
systemFeaturesContract,
} from './console'
import {
createFileContract,
createFolderContract,
deleteNodeContract,
getFileContentContract,
moveNodeContract,
publishContract,
renameNodeContract,
reorderNodeContract,
treeContract,
updateFileContentContract,
} from './console/app-asset'
import {
activateSandboxProviderContract,
deleteSandboxProviderConfigContract,
@ -32,6 +44,18 @@ export const consoleRouterContract = {
deleteSandboxProviderConfig: deleteSandboxProviderConfigContract,
activateSandboxProvider: activateSandboxProviderContract,
getActiveSandboxProvider: getActiveSandboxProviderContract,
appAsset: {
tree: treeContract,
createFolder: createFolderContract,
createFile: createFileContract,
getFileContent: getFileContentContract,
updateFileContent: updateFileContentContract,
deleteNode: deleteNodeContract,
renameNode: renameNodeContract,
moveNode: moveNodeContract,
reorderNode: reorderNodeContract,
publish: publishContract,
},
}
export type ConsoleInputs = InferContractRouterInputs<typeof consoleRouterContract>

View File

@ -0,0 +1,286 @@
import type {
AppAssetNode,
CreateFolderPayload,
MoveNodePayload,
RenameNodePayload,
ReorderNodePayload,
UpdateFileContentPayload,
} from '@/types/app-asset'
import {
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { consoleClient, consoleQuery } from '@/service/client'
import { upload } from './base'
export const useGetAppAssetTree = (appId: string) => {
return useQuery({
queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId } } }),
queryFn: () => consoleClient.appAsset.tree({ params: { appId } }),
enabled: !!appId,
})
}
export const useCreateAppAssetFolder = () => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: consoleQuery.appAsset.createFolder.mutationKey(),
mutationFn: ({ appId, payload }: { appId: string, payload: CreateFolderPayload }) => {
return consoleClient.appAsset.createFolder({
params: { appId },
body: payload,
})
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }),
})
},
})
}
export const useCreateAppAssetFile = () => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: consoleQuery.appAsset.createFile.mutationKey(),
mutationFn: async ({
appId,
name,
file,
parentId,
onProgress,
}: {
appId: string
name: string
file: File
parentId?: string | null
onProgress?: (progress: number) => void
}): Promise<AppAssetNode> => {
const formData = new FormData()
formData.append('name', name)
formData.append('file', file)
if (parentId)
formData.append('parent_id', parentId)
const xhr = new XMLHttpRequest()
return upload(
{
xhr,
data: formData,
onprogress: onProgress
? (e) => {
if (e.lengthComputable)
onProgress(Math.round((e.loaded / e.total) * 100))
}
: undefined,
},
false,
`/apps/${appId}/assets/files`,
) as Promise<AppAssetNode>
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }),
})
},
})
}
export const useGetAppAssetFileContent = (appId: string, nodeId: string) => {
return useQuery({
queryKey: consoleQuery.appAsset.getFileContent.queryKey({ input: { params: { appId, nodeId } } }),
queryFn: () => consoleClient.appAsset.getFileContent({ params: { appId, nodeId } }),
enabled: !!appId && !!nodeId,
})
}
export const useUpdateAppAssetFileContent = () => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: consoleQuery.appAsset.updateFileContent.mutationKey(),
mutationFn: ({
appId,
nodeId,
payload,
}: {
appId: string
nodeId: string
payload: UpdateFileContentPayload
}) => {
return consoleClient.appAsset.updateFileContent({
params: { appId, nodeId },
body: payload,
})
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }),
})
queryClient.invalidateQueries({
queryKey: consoleQuery.appAsset.getFileContent.queryKey({
input: { params: { appId: variables.appId, nodeId: variables.nodeId } },
}),
})
},
})
}
export const useUpdateAppAssetFileByUpload = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
appId,
nodeId,
file,
onProgress,
}: {
appId: string
nodeId: string
file: File
onProgress?: (progress: number) => void
}): Promise<AppAssetNode> => {
const formData = new FormData()
formData.append('file', file)
const xhr = new XMLHttpRequest()
return upload(
{
xhr,
method: 'PUT',
data: formData,
onprogress: onProgress
? (e) => {
if (e.lengthComputable)
onProgress(Math.round((e.loaded / e.total) * 100))
}
: undefined,
},
false,
`/apps/${appId}/assets/files/${nodeId}`,
) as Promise<AppAssetNode>
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }),
})
queryClient.invalidateQueries({
queryKey: consoleQuery.appAsset.getFileContent.queryKey({
input: { params: { appId: variables.appId, nodeId: variables.nodeId } },
}),
})
},
})
}
export const useDeleteAppAssetNode = () => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: consoleQuery.appAsset.deleteNode.mutationKey(),
mutationFn: ({ appId, nodeId }: { appId: string, nodeId: string }) => {
return consoleClient.appAsset.deleteNode({
params: { appId, nodeId },
})
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }),
})
},
})
}
export const useRenameAppAssetNode = () => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: consoleQuery.appAsset.renameNode.mutationKey(),
mutationFn: ({
appId,
nodeId,
payload,
}: {
appId: string
nodeId: string
payload: RenameNodePayload
}) => {
return consoleClient.appAsset.renameNode({
params: { appId, nodeId },
body: payload,
})
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }),
})
},
})
}
export const useMoveAppAssetNode = () => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: consoleQuery.appAsset.moveNode.mutationKey(),
mutationFn: ({
appId,
nodeId,
payload,
}: {
appId: string
nodeId: string
payload: MoveNodePayload
}) => {
return consoleClient.appAsset.moveNode({
params: { appId, nodeId },
body: payload,
})
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }),
})
},
})
}
export const useReorderAppAssetNode = () => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: consoleQuery.appAsset.reorderNode.mutationKey(),
mutationFn: ({
appId,
nodeId,
payload,
}: {
appId: string
nodeId: string
payload: ReorderNodePayload
}) => {
return consoleClient.appAsset.reorderNode({
params: { appId, nodeId },
body: payload,
})
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }),
})
},
})
}
export const usePublishAppAssets = () => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: consoleQuery.appAsset.publish.mutationKey(),
mutationFn: (appId: string) => {
return consoleClient.appAsset.publish({
params: { appId },
})
},
onSuccess: (_, appId) => {
queryClient.invalidateQueries({
queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId } } }),
})
},
})
}

149
web/types/app-asset.ts Normal file
View File

@ -0,0 +1,149 @@
/**
* App Asset Types
*
* Types for app asset management API - file tree operations,
* file content management, and asset publishing.
*/
/**
* Node type enumeration for asset tree nodes
*/
export type AssetNodeType = 'file' | 'folder'
/**
* Asset node representation (flat storage format)
* Used in responses for create, update, rename, move, reorder operations
*/
export type AppAssetNode = {
/** Unique identifier (UUID) */
id: string
/** Node type: file or folder */
node_type: AssetNodeType
/** Node name (filename or folder name) */
name: string
/** Parent folder ID, null for root level */
parent_id: string | null
/** Sort order within parent folder (0-based) */
order: number
/** File extension without dot, empty for folders */
extension: string
/** File size in bytes, 0 for folders */
size: number
/** SHA-256 checksum of file content, empty for folders */
checksum: string
}
/**
* Asset tree view node (nested format with computed path)
* Used in tree response with hierarchical structure
*/
export type AppAssetTreeView = {
/** Unique identifier (UUID) */
id: string
/** Node type: file or folder */
node_type: AssetNodeType
/** Node name */
name: string
/** Full path from root, e.g. '/folder/file.txt' */
path: string
/** File extension without dot */
extension: string
/** File size in bytes */
size: number
/** SHA-256 checksum */
checksum: string
/** Child nodes (for folders) */
children: AppAssetTreeView[]
}
/**
* Asset tree response (GET /apps/{app_id}/assets/tree)
*/
export type AppAssetTreeResponse = {
children: AppAssetTreeView[]
}
/**
* File content response (GET /apps/{app_id}/assets/files/{node_id})
*/
export type AppAssetFileContentResponse = {
content: string
}
/**
* Delete node response (DELETE /apps/{app_id}/assets/nodes/{node_id})
*/
export type AppAssetDeleteResponse = {
result: 'success'
}
/**
* Published asset tree structure (flat node list)
*/
export type AppAssetFileTree = {
nodes: AppAssetNode[]
}
/**
* Publish response (POST /apps/{app_id}/assets/publish)
*/
export type AppAssetPublishResponse = {
/** Published version ID */
id: string
/** Version timestamp */
version: string
/** Asset tree snapshot */
asset_tree: AppAssetFileTree
}
/**
* Request payload for creating a folder
*/
export type CreateFolderPayload = {
/** Folder name (1-255 characters) */
name: string
/** Parent folder ID, null/undefined for root */
parent_id?: string | null
}
/**
* Request payload for creating a file (form data)
*/
export type CreateFilePayload = {
/** File name (1-255 characters) */
name: string
/** Parent folder ID, empty or undefined for root */
parent_id?: string | null
}
/**
* Request payload for updating file content (JSON)
*/
export type UpdateFileContentPayload = {
/** New file content (UTF-8) */
content: string
}
/**
* Request payload for renaming a node
*/
export type RenameNodePayload = {
/** New name (1-255 characters) */
name: string
}
/**
* Request payload for moving a node
*/
export type MoveNodePayload = {
/** Target parent folder ID, null for root */
parent_id: string | null
}
/**
* Request payload for reordering a node
*/
export type ReorderNodePayload = {
/** Place after this node ID, null for first position */
after_node_id: string | null
}