mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-06 02:48:28 +00:00
refactor(web): move portal APIs under api/web
This commit is contained in:
parent
17dcb4807a
commit
2b6f4e335e
17 changed files with 76 additions and 44 deletions
|
|
@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/me")
|
||||
@RequestMapping({"/api/v1/me", "/api/web/me"})
|
||||
public class MeController extends BaseApiController {
|
||||
|
||||
private final MySkillAppService mySkillAppService;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/namespaces")
|
||||
@RequestMapping({"/api/v1/namespaces", "/api/web/namespaces"})
|
||||
public class NamespaceController extends BaseApiController {
|
||||
|
||||
private final NamespaceService namespaceService;
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import java.util.Map;
|
|||
import java.util.Set;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/promotions")
|
||||
@RequestMapping({"/api/v1/promotions", "/api/web/promotions"})
|
||||
public class PromotionController extends BaseApiController {
|
||||
|
||||
private final PromotionService promotionService;
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ import java.util.Map;
|
|||
import java.util.Set;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/reviews")
|
||||
@RequestMapping({"/api/v1/reviews", "/api/web/reviews"})
|
||||
public class ReviewController extends BaseApiController {
|
||||
|
||||
private final ReviewService reviewService;
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import java.util.Map;
|
|||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/skills")
|
||||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
public class SkillController extends BaseApiController {
|
||||
|
||||
private final SkillQueryService skillQueryService;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import java.io.IOException;
|
|||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/skills")
|
||||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
public class SkillPublishController extends BaseApiController {
|
||||
|
||||
private final SkillPublishService skillPublishService;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.*;
|
|||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/skills")
|
||||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
public class SkillRatingController extends BaseApiController {
|
||||
|
||||
private final SkillRatingService skillRatingService;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.*;
|
|||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/skills")
|
||||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
public class SkillSearchController extends BaseApiController {
|
||||
|
||||
private final SkillSearchAppService skillSearchAppService;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/skills")
|
||||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
public class SkillStarController extends BaseApiController {
|
||||
|
||||
private final SkillStarService skillStarService;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ import java.util.Map;
|
|||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/skills/{namespace}/{slug}/tags")
|
||||
@RequestMapping({
|
||||
"/api/v1/skills/{namespace}/{slug}/tags",
|
||||
"/api/web/skills/{namespace}/{slug}/tags"
|
||||
})
|
||||
public class SkillTagController extends BaseApiController {
|
||||
|
||||
private final SkillTagService skillTagService;
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ public class SecurityConfig {
|
|||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
.csrfTokenRequestHandler(csrfHandler)
|
||||
.ignoringRequestMatchers("/api/v1/**", "/api/compat/**")
|
||||
.ignoringRequestMatchers("/api/v1/**", "/api/web/**", "/api/compat/**")
|
||||
)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
|
|
@ -92,7 +92,13 @@ public class SecurityConfig {
|
|||
"/api/compat/v1/resolve/**",
|
||||
"/api/compat/v1/download/**"
|
||||
).permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/skills/*/star", "/api/v1/skills/*/rating").authenticated()
|
||||
.requestMatchers(
|
||||
HttpMethod.GET,
|
||||
"/api/v1/skills/*/star",
|
||||
"/api/v1/skills/*/rating",
|
||||
"/api/web/skills/*/star",
|
||||
"/api/web/skills/*/rating"
|
||||
).authenticated()
|
||||
.requestMatchers(
|
||||
HttpMethod.GET,
|
||||
"/api/v1/skills",
|
||||
|
|
@ -104,9 +110,25 @@ public class SecurityConfig {
|
|||
"/api/v1/skills/*/*/resolve",
|
||||
"/api/v1/skills/*/*/tags",
|
||||
"/api/v1/skills/*/*/tags/*/files",
|
||||
"/api/v1/skills/*/*/tags/*/file"
|
||||
"/api/v1/skills/*/*/tags/*/file",
|
||||
"/api/web/skills",
|
||||
"/api/web/skills/*/*",
|
||||
"/api/web/skills/*/*/versions",
|
||||
"/api/web/skills/*/*/versions/*",
|
||||
"/api/web/skills/*/*/versions/*/files",
|
||||
"/api/web/skills/*/*/versions/*/file",
|
||||
"/api/web/skills/*/*/resolve",
|
||||
"/api/web/skills/*/*/tags",
|
||||
"/api/web/skills/*/*/tags/*/files",
|
||||
"/api/web/skills/*/*/tags/*/file"
|
||||
).permitAll()
|
||||
.requestMatchers(
|
||||
HttpMethod.GET,
|
||||
"/api/v1/namespaces",
|
||||
"/api/v1/namespaces/*",
|
||||
"/api/web/namespaces",
|
||||
"/api/web/namespaces/*"
|
||||
).permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/namespaces", "/api/v1/namespaces/*").permitAll()
|
||||
.requestMatchers("/api/v1/admin/**").hasAnyRole("SUPER_ADMIN", "SKILL_ADMIN", "USER_ADMIN", "AUDITOR")
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,8 +24,12 @@ public class ApiTokenScopeService {
|
|||
ScopeRule.allow("GET", "/api/v1/whoami"),
|
||||
ScopeRule.allow("GET", "/api/v1/skills"),
|
||||
ScopeRule.allow("GET", "/api/v1/skills/**"),
|
||||
ScopeRule.allow("GET", "/api/web/skills"),
|
||||
ScopeRule.allow("GET", "/api/web/skills/**"),
|
||||
ScopeRule.allow("GET", "/api/v1/namespaces"),
|
||||
ScopeRule.allow("GET", "/api/v1/namespaces/*"),
|
||||
ScopeRule.allow("GET", "/api/web/namespaces"),
|
||||
ScopeRule.allow("GET", "/api/web/namespaces/*"),
|
||||
ScopeRule.allow("GET", "/api/compat/v1/search"),
|
||||
ScopeRule.allow("GET", "/api/compat/v1/resolve/**"),
|
||||
ScopeRule.allow("GET", "/api/compat/v1/whoami"),
|
||||
|
|
@ -39,6 +43,7 @@ public class ApiTokenScopeService {
|
|||
ScopeRule.require(null, "/api/v1/tokens", "token:manage"),
|
||||
ScopeRule.require(null, "/api/v1/tokens/**", "token:manage"),
|
||||
ScopeRule.require("POST", "/api/v1/skills/*/publish", "skill:publish"),
|
||||
ScopeRule.require("POST", "/api/web/skills/*/publish", "skill:publish"),
|
||||
ScopeRule.require("POST", "/api/v1/publish", "skill:publish"),
|
||||
ScopeRule.require("POST", "/api/compat/v1/publish", "skill:publish")
|
||||
);
|
||||
|
|
@ -96,7 +101,7 @@ public class ApiTokenScopeService {
|
|||
}
|
||||
|
||||
private boolean isApiPath(String path) {
|
||||
return path != null && (path.startsWith("/api/v1/") || path.startsWith("/api/compat/"));
|
||||
return path != null && (path.startsWith("/api/v1/") || path.startsWith("/api/web/") || path.startsWith("/api/compat/"));
|
||||
}
|
||||
|
||||
public record AuthorizationDecision(boolean allowed, String requiredScope, String message) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import i18n from '@/i18n/config'
|
|||
|
||||
export { ApiError }
|
||||
|
||||
export const WEB_API_PREFIX = '/api/web'
|
||||
|
||||
type RuntimeConfig = {
|
||||
apiBaseUrl?: string
|
||||
appBaseUrl?: string
|
||||
|
|
@ -495,16 +497,16 @@ export const reviewApi = {
|
|||
searchParams.set('page', String(params.page ?? 0))
|
||||
searchParams.set('size', String(params.size ?? 20))
|
||||
return fetchJson<{ items: ReviewTask[]; total: number; page: number; size: number }>(
|
||||
`/api/v1/reviews?${searchParams.toString()}`,
|
||||
`${WEB_API_PREFIX}/reviews?${searchParams.toString()}`,
|
||||
)
|
||||
},
|
||||
|
||||
async get(id: number): Promise<ReviewTask> {
|
||||
return fetchJson<ReviewTask>(`/api/v1/reviews/${id}`)
|
||||
return fetchJson<ReviewTask>(`${WEB_API_PREFIX}/reviews/${id}`)
|
||||
},
|
||||
|
||||
async approve(id: number, comment?: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/reviews/${id}/approve`, {
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/reviews/${id}/approve`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -514,7 +516,7 @@ export const reviewApi = {
|
|||
},
|
||||
|
||||
async reject(id: number, comment: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/reviews/${id}/reject`, {
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/reviews/${id}/reject`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -531,16 +533,16 @@ export const promotionApi = {
|
|||
searchParams.set('page', String(params.page ?? 0))
|
||||
searchParams.set('size', String(params.size ?? 20))
|
||||
return fetchJson<{ items: PromotionTask[]; total: number; page: number; size: number }>(
|
||||
`/api/v1/promotions?${searchParams.toString()}`,
|
||||
`${WEB_API_PREFIX}/promotions?${searchParams.toString()}`,
|
||||
)
|
||||
},
|
||||
|
||||
async get(id: number): Promise<PromotionTask> {
|
||||
return fetchJson<PromotionTask>(`/api/v1/promotions/${id}`)
|
||||
return fetchJson<PromotionTask>(`${WEB_API_PREFIX}/promotions/${id}`)
|
||||
},
|
||||
|
||||
async approve(id: number, comment?: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/promotions/${id}/approve`, {
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/promotions/${id}/approve`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -550,7 +552,7 @@ export const promotionApi = {
|
|||
},
|
||||
|
||||
async reject(id: number, comment?: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/promotions/${id}/reject`, {
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/promotions/${id}/reject`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -562,7 +564,7 @@ export const promotionApi = {
|
|||
|
||||
export const meApi = {
|
||||
async getStars(): Promise<SkillSummary[]> {
|
||||
return fetchJson<SkillSummary[]>('/api/v1/me/stars')
|
||||
return fetchJson<SkillSummary[]>(`${WEB_API_PREFIX}/me/stars`)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, fetchJson, getCsrfHeaders } from '@/api/client'
|
||||
import { ApiError, fetchJson, getCsrfHeaders, WEB_API_PREFIX } from '@/api/client'
|
||||
|
||||
interface UserRating {
|
||||
score: number
|
||||
|
|
@ -8,7 +8,7 @@ interface UserRating {
|
|||
|
||||
async function getUserRating(skillId: number): Promise<UserRating> {
|
||||
try {
|
||||
return await fetchJson<UserRating>(`/api/v1/skills/${skillId}/rating`)
|
||||
return await fetchJson<UserRating>(`${WEB_API_PREFIX}/skills/${skillId}/rating`)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return { score: 0, rated: false }
|
||||
|
|
@ -18,7 +18,7 @@ async function getUserRating(skillId: number): Promise<UserRating> {
|
|||
}
|
||||
|
||||
async function rateSkill(skillId: number, rating: number): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/skills/${skillId}/rating`, {
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${skillId}/rating`, {
|
||||
method: 'PUT',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, fetchJson, getCsrfHeaders } from '@/api/client'
|
||||
import { ApiError, fetchJson, getCsrfHeaders, WEB_API_PREFIX } from '@/api/client'
|
||||
|
||||
interface StarStatus {
|
||||
starred: boolean
|
||||
|
|
@ -7,7 +7,7 @@ interface StarStatus {
|
|||
|
||||
async function getStarStatus(skillId: number): Promise<StarStatus> {
|
||||
try {
|
||||
const starred = await fetchJson<boolean>(`/api/v1/skills/${skillId}/star`)
|
||||
const starred = await fetchJson<boolean>(`${WEB_API_PREFIX}/skills/${skillId}/star`)
|
||||
return { starred }
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
|
|
@ -19,12 +19,12 @@ async function getStarStatus(skillId: number): Promise<StarStatus> {
|
|||
|
||||
async function toggleStar(skillId: number, starred: boolean): Promise<void> {
|
||||
if (starred) {
|
||||
await fetchJson<void>(`/api/v1/skills/${skillId}/star`, {
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${skillId}/star`, {
|
||||
method: 'DELETE',
|
||||
headers: getCsrfHeaders(),
|
||||
})
|
||||
} else {
|
||||
await fetchJson<void>(`/api/v1/skills/${skillId}/star`, {
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${skillId}/star`, {
|
||||
method: 'PUT',
|
||||
headers: getCsrfHeaders(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { InstallCommand } from '@/features/skill/install-command'
|
|||
import { RatingInput } from '@/features/social/rating-input'
|
||||
import { StarButton } from '@/features/social/star-button'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { adminApi } from '@/api/client'
|
||||
import { adminApi, WEB_API_PREFIX } from '@/api/client'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { NamespaceBadge } from '@/shared/components/namespace-badge'
|
||||
|
|
@ -67,7 +67,7 @@ export function SkillDetailPage() {
|
|||
return
|
||||
}
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
const downloadUrl = `/api/v1/skills/${cleanNamespace}/${slug}/versions/${latestVersion.version}/download`
|
||||
const downloadUrl = `${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${latestVersion.version}/download`
|
||||
window.open(downloadUrl, '_blank')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { SkillSummary, SkillDetail, SkillVersion, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types'
|
||||
import { fetchJson, fetchText, getCsrfHeaders, meApi } from '@/api/client'
|
||||
import { fetchJson, fetchText, getCsrfHeaders, meApi, WEB_API_PREFIX } from '@/api/client'
|
||||
|
||||
const PUBLISH_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
||||
|
|
@ -15,36 +15,36 @@ async function searchSkills(params: SearchParams): Promise<PagedResponse<SkillSu
|
|||
if (params.page !== undefined) queryParams.append('page', String(params.page))
|
||||
if (params.size !== undefined) queryParams.append('size', String(params.size))
|
||||
|
||||
return fetchJson<PagedResponse<SkillSummary>>(`/api/v1/skills?${queryParams.toString()}`)
|
||||
return fetchJson<PagedResponse<SkillSummary>>(`${WEB_API_PREFIX}/skills?${queryParams.toString()}`)
|
||||
}
|
||||
|
||||
async function getSkillDetail(namespace: string, slug: string): Promise<SkillDetail> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
return fetchJson<SkillDetail>(`/api/v1/skills/${cleanNamespace}/${slug}`)
|
||||
return fetchJson<SkillDetail>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}`)
|
||||
}
|
||||
|
||||
async function getSkillVersions(namespace: string, slug: string): Promise<SkillVersion[]> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
const page = await fetchJson<PagedResponse<SkillVersion>>(`/api/v1/skills/${cleanNamespace}/${slug}/versions`)
|
||||
const page = await fetchJson<PagedResponse<SkillVersion>>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions`)
|
||||
return page.items
|
||||
}
|
||||
|
||||
async function getSkillFiles(namespace: string, slug: string, version: string): Promise<SkillFile[]> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
return fetchJson<SkillFile[]>(`/api/v1/skills/${cleanNamespace}/${slug}/versions/${version}/files`)
|
||||
return fetchJson<SkillFile[]>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/files`)
|
||||
}
|
||||
|
||||
async function getSkillReadme(namespace: string, slug: string, version: string): Promise<string> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
try {
|
||||
return await fetchText(`/api/v1/skills/${cleanNamespace}/${slug}/versions/${version}/file?path=SKILL.md`)
|
||||
return await fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/file?path=SKILL.md`)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function getMySkills(): Promise<SkillSummary[]> {
|
||||
return fetchJson<SkillSummary[]>('/api/v1/me/skills')
|
||||
return fetchJson<SkillSummary[]>(`${WEB_API_PREFIX}/me/skills`)
|
||||
}
|
||||
|
||||
async function getMyStars(): Promise<SkillSummary[]> {
|
||||
|
|
@ -52,18 +52,18 @@ async function getMyStars(): Promise<SkillSummary[]> {
|
|||
}
|
||||
|
||||
async function getMyNamespaces(): Promise<Namespace[]> {
|
||||
const page = await fetchJson<PagedResponse<Namespace>>('/api/v1/namespaces')
|
||||
const page = await fetchJson<PagedResponse<Namespace>>(`${WEB_API_PREFIX}/namespaces`)
|
||||
return page.items
|
||||
}
|
||||
|
||||
async function getNamespaceDetail(slug: string): Promise<Namespace> {
|
||||
const cleanSlug = slug.startsWith('@') ? slug.slice(1) : slug
|
||||
return fetchJson<Namespace>(`/api/v1/namespaces/${cleanSlug}`)
|
||||
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${cleanSlug}`)
|
||||
}
|
||||
|
||||
async function getNamespaceMembers(slug: string): Promise<NamespaceMember[]> {
|
||||
const cleanSlug = slug.startsWith('@') ? slug.slice(1) : slug
|
||||
const page = await fetchJson<PagedResponse<NamespaceMember>>(`/api/v1/namespaces/${cleanSlug}/members`)
|
||||
const page = await fetchJson<PagedResponse<NamespaceMember>>(`${WEB_API_PREFIX}/namespaces/${cleanSlug}/members`)
|
||||
return page.items
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ async function publishSkill(params: { namespace: string; file: File; visibility:
|
|||
formData.append('file', params.file)
|
||||
formData.append('visibility', params.visibility)
|
||||
|
||||
return fetchJson<PublishResult>(`/api/v1/skills/${cleanNamespace}/publish`, {
|
||||
return fetchJson<PublishResult>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/publish`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders(),
|
||||
body: formData,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue