From 0e8e23c01064b3abe9458bc52470f68059299634 Mon Sep 17 00:00:00 2001 From: Aofei Sheng Date: Fri, 27 Mar 2026 18:29:16 +0800 Subject: [PATCH] fix: handle canonicalized route responses Handle moved-resource responses from the backend without generic write retries, and retry updateProject once with the canonical route so saving existing projects still works after a project rename. Replace unresolved user, project, and editor routes with canonical backend values after loading so subsequent UI behavior uses canonical identifiers consistently. Updates #2993 Signed-off-by: Aofei Sheng --- spx-gui/src/apis/common/client.test.ts | 115 ++++++++++++++++++ spx-gui/src/apis/common/client.ts | 47 ++++++- spx-gui/src/apis/common/exception.ts | 40 +++--- spx-gui/src/apis/project.test.ts | 109 +++++++++++++++++ spx-gui/src/apis/project.ts | 32 ++++- spx-gui/src/apis/user.test.ts | 50 ++++++++ spx-gui/src/apis/user.ts | 10 ++ .../community/user/ModifyUsernameModal.vue | 9 +- .../src/components/community/user/index.ts | 12 +- .../components/editor/navbar/EditorNavbar.vue | 7 +- .../components/project/ProjectCreateModal.vue | 9 +- .../project/ProjectModifyNameModal.vue | 9 +- spx-gui/src/components/project/index.ts | 8 +- spx-gui/src/pages/community/project.vue | 81 ++++++++---- spx-gui/src/pages/community/user/index.vue | 16 +++ spx-gui/src/pages/editor/index.vue | 32 ++++- spx-gui/src/utils/project-route.test.ts | 21 ++++ spx-gui/src/utils/project-route.ts | 30 +++++ 18 files changed, 544 insertions(+), 93 deletions(-) create mode 100644 spx-gui/src/apis/common/client.test.ts create mode 100644 spx-gui/src/apis/project.test.ts create mode 100644 spx-gui/src/apis/user.test.ts create mode 100644 spx-gui/src/utils/project-route.test.ts create mode 100644 spx-gui/src/utils/project-route.ts diff --git a/spx-gui/src/apis/common/client.test.ts b/spx-gui/src/apis/common/client.test.ts new file mode 100644 index 000000000..cf056dc11 --- /dev/null +++ b/spx-gui/src/apis/common/client.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiException, ApiExceptionCode, isQuotaExceededMeta } from './exception' +import { Client } from './client' + +function makeMovedResponse(canonicalPath: string) { + return new Response( + JSON.stringify({ + code: ApiExceptionCode.errorResourceMoved, + msg: 'Resource moved', + canonical: { + path: canonicalPath + } + }), + { + status: 409, + headers: { + 'Content-Type': 'application/json' + } + } + ) +} + +function makeQuotaExceededResponse(retryAfter: string) { + return new Response( + JSON.stringify({ + code: ApiExceptionCode.errorQuotaExceeded, + msg: 'Quota exceeded' + }), + { + status: 403, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': retryAfter + } + } + ) +} + +describe('Client', () => { + let client: Client + let fetchMock: ReturnType> + + beforeEach(() => { + fetchMock = vi.fn() + client = new Client({ + baseUrl: 'https://api.example.com', + fetchFn: fetchMock + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('path-based moved conflicts', () => { + it('should surface the moved conflict without retrying', async () => { + fetchMock.mockResolvedValueOnce(makeMovedResponse('/project/john/demo/view')) + + try { + await client.post('/project/John/demo/view') + throw new Error('expected moved conflict') + } catch (e) { + expect(e).toBeInstanceOf(ApiException) + expect(e).toMatchObject({ + code: ApiExceptionCode.errorResourceMoved, + meta: { + path: '/project/john/demo/view' + } + }) + } + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(new URL((fetchMock.mock.calls[0]![0] as Request).url).pathname).toBe('/project/John/demo/view') + }) + }) + + describe('quota exceeded metadata', () => { + it('should parse retry-after metadata from headers', async () => { + const retryAfter = 'Wed, 09 Apr 2026 08:00:00 GMT' + fetchMock.mockResolvedValueOnce(makeQuotaExceededResponse(retryAfter)) + + try { + await client.get('/quota') + throw new Error('expected quota exceeded error') + } catch (e) { + expect(e).toBeInstanceOf(ApiException) + expect(e).toMatchObject({ + code: ApiExceptionCode.errorQuotaExceeded + }) + expect(isQuotaExceededMeta((e as ApiException).code, (e as ApiException).meta)).toBe(true) + expect((e as ApiException).meta).toMatchObject({ + retryAfter: new Date(retryAfter).valueOf() + }) + } + }) + }) + + describe('default fetch binding', () => { + it('should call the global fetch with the global receiver when fetchFn is not injected', async () => { + const globalFetchMock = vi.fn(function (this: typeof globalThis, _req: RequestInfo | URL, _init?: RequestInit) { + expect(this).toBe(globalThis) + return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 })) + }) + vi.stubGlobal('fetch', globalFetchMock) + + const defaultClient = new Client({ + baseUrl: 'https://api.example.com' + }) + + await defaultClient.get('/health') + + expect(globalFetchMock).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/spx-gui/src/apis/common/client.ts b/spx-gui/src/apis/common/client.ts index bd0f33dac..f0306eb35 100644 --- a/spx-gui/src/apis/common/client.ts +++ b/spx-gui/src/apis/common/client.ts @@ -3,10 +3,11 @@ */ import * as Sentry from '@sentry/vue' +import dayjs from 'dayjs' import { apiBaseUrl } from '@/utils/env' import { TimeoutException } from '@/utils/exception/base' import { mergeSignals } from '@/utils/disposable' -import { ApiException } from './exception' +import { ApiException, ApiExceptionCode, type MovedResourceCanonical, type QuotaExceededMeta } from './exception' import { parseSSE, type SSEEvent } from './sse' /** Response body when exception encountered for API calling */ @@ -15,12 +16,36 @@ export type ApiExceptionPayload = { code: number /** Message for developer reading */ msg: string + canonical?: MovedResourceCanonical } function isApiExceptionPayload(body: any): body is ApiExceptionPayload { return body && typeof body.code === 'number' && typeof body.msg === 'string' } +function getQuotaExceededMeta(headers: Headers): QuotaExceededMeta { + const retryAfter = headers.get('Retry-After') + let date + if (retryAfter != null) { + const seconds = Number(retryAfter) + date = Number.isFinite(seconds) ? dayjs().add(seconds, 's') : dayjs(retryAfter) + } + return { + retryAfter: date?.isValid() ? date.valueOf() : null + } +} + +function getApiExceptionMeta(code: number, resp: Response, payload: ApiExceptionPayload): unknown { + switch (code) { + case ApiExceptionCode.errorQuotaExceeded: + return getQuotaExceededMeta(resp.headers) + case ApiExceptionCode.errorResourceMoved: + return payload.canonical ?? null + default: + return null + } +} + /** TokenProvider provides access token used for the Authorization header */ export type TokenProvider = () => Promise @@ -41,13 +66,24 @@ export type JSONSSEEvent = { data: unknown } +export type ClientOptions = { + baseUrl?: string + fetchFn?: typeof fetch +} + export class Client { + constructor(options: ClientOptions = {}) { + this.baseUrl = options.baseUrl ?? apiBaseUrl + this.fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis) + } + private tokenProvider: TokenProvider = async () => null setTokenProvider(provider: TokenProvider) { this.tokenProvider = provider } - private baseUrl = apiBaseUrl + private baseUrl: string + private fetchFn: typeof fetch private defaultTimeout = 10 * 1000 // 10 seconds /** Prepare request object, stringifying payload as JSON */ @@ -74,7 +110,7 @@ export class Client { const timeoutCtrl = new AbortController() const timeoutTimer = setTimeout(() => timeoutCtrl.abort(new TimeoutException()), timeout) const signal = mergeSignals(options?.signal, timeoutCtrl.signal) - const resp = await fetch(req, { signal }).finally(() => clearTimeout(timeoutTimer)) + const resp = await this.fetchFn(req, { signal }).finally(() => clearTimeout(timeoutTimer)) if (!resp.ok) { let payload: ApiExceptionPayload | undefined try { @@ -84,7 +120,10 @@ export class Client { // ignore } if (payload == null) throw new Error(`status ${resp.status} for api call: ${req.url.slice(0, 200)}`) - throw new ApiException(payload.code, payload.msg, { req, resp }) + throw new ApiException(payload.code, payload.msg, { + req, + meta: getApiExceptionMeta(payload.code, resp, payload) + }) } return resp } diff --git a/spx-gui/src/apis/common/exception.ts b/spx-gui/src/apis/common/exception.ts index 6a65372e4..b070f4fbd 100644 --- a/spx-gui/src/apis/common/exception.ts +++ b/spx-gui/src/apis/common/exception.ts @@ -4,7 +4,6 @@ import type { LocaleMessage } from '@/utils/i18n' import { Exception } from '@/utils/exception' -import dayjs from 'dayjs' export class ApiException extends Exception { name = 'ApiException' @@ -14,11 +13,11 @@ export class ApiException extends Exception { constructor( public code: number, message: string, - { req, resp }: { req: Request; resp: Response } + { req, meta }: { req: Request; meta?: unknown } ) { super(`[${code}] ${message} (${req.method} ${req.url.slice(0, 200)})`) this.userMessage = codeMessages[this.code as ApiExceptionCode] ?? null - this.meta = codeMetas[this.code as ApiExceptionCode]?.(resp.headers) ?? null + this.meta = meta ?? null } } @@ -28,29 +27,26 @@ export enum ApiExceptionCode { errorForbidden = 40300, errorQuotaExceeded = 40301, errorNotFound = 40400, + errorResourceMoved = 40901, errorTooManyRequests = 42900, errorRateLimitExceeded = 42901, errorScratchFeatureNotSupported = 50101, errorUnknown = 50000 } +export type MovedResourceCanonical = { + path: string + username?: string + owner?: string + name?: string + release?: string +} + export type QuotaExceededMeta = { // milliseconds or null retryAfter: number | null } -const codeMetas: { [key in ApiExceptionCode]?: (headers: Headers) => unknown } = { - [ApiExceptionCode.errorQuotaExceeded]: (headers): QuotaExceededMeta => { - const retryAfter = headers.get('Retry-After') - let date - if (retryAfter != null) { - const seconds = Number(retryAfter) - date = Number.isFinite(seconds) ? dayjs().add(seconds, 's') : dayjs(retryAfter) - } - return { - retryAfter: date?.isValid() ? date.valueOf() : null - } - } -} + export function isQuotaExceededMeta(code: number, meta: unknown): meta is QuotaExceededMeta { return code === ApiExceptionCode.errorQuotaExceeded && meta != null } @@ -72,6 +68,14 @@ const codeMessages: Record = { en: 'quota exceeded', zh: '超出配额' }, + [ApiExceptionCode.errorNotFound]: { + en: 'resource not exist', + zh: '资源不存在' + }, + [ApiExceptionCode.errorResourceMoved]: { + en: 'resource moved', + zh: '资源已迁移' + }, [ApiExceptionCode.errorTooManyRequests]: { en: 'too many requests', zh: '请求太频繁了' @@ -80,10 +84,6 @@ const codeMessages: Record = { en: 'rate limit exceeded, please retry later', zh: '触发频率限制,请稍后重试' }, - [ApiExceptionCode.errorNotFound]: { - en: 'resource not exist', - zh: '资源不存在' - }, [ApiExceptionCode.errorScratchFeatureNotSupported]: { en: 'Some Scratch features are not supported yet', zh: '部分 Scratch 特性暂不支持' diff --git a/spx-gui/src/apis/project.test.ts b/spx-gui/src/apis/project.test.ts new file mode 100644 index 000000000..950d98cd0 --- /dev/null +++ b/spx-gui/src/apis/project.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { client } from './common' +import { ApiException, ApiExceptionCode, type MovedResourceCanonical } from './common/exception' +import { isProjectNameTaken, updateProject } from './project' + +function makeMovedException(canonical: MovedResourceCanonical) { + return new ApiException(ApiExceptionCode.errorResourceMoved, 'Resource moved', { + req: new Request('https://api.example.com/project/John/Demo', { method: 'PATCH' }), + meta: canonical + }) +} + +describe('updateProject', () => { + let mockedPatch: ReturnType + + beforeEach(() => { + mockedPatch = vi.spyOn(client, 'patch') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should retry once with the canonical project route', async () => { + mockedPatch + .mockRejectedValueOnce(makeMovedException({ path: '/project/john/demo', owner: 'john', name: 'demo' })) + .mockResolvedValueOnce({ + owner: 'john', + name: 'demo' + }) + + const saved = await updateProject('John', 'Demo', { displayName: 'Demo' }) + + expect(saved).toMatchObject({ + owner: 'john', + name: 'demo' + }) + expect(mockedPatch).toHaveBeenCalledTimes(2) + expect(mockedPatch.mock.calls[0]![0]).toBe('/project/John/Demo') + expect(mockedPatch.mock.calls[1]![0]).toBe('/project/john/demo') + }) + + it('should surface moved errors without canonical project route fields', async () => { + const movedError = makeMovedException({ path: '/project/john/demo' }) + mockedPatch.mockRejectedValueOnce(movedError) + + await expect(updateProject('John', 'Demo', { displayName: 'Demo' })).rejects.toBe(movedError) + + expect(mockedPatch).toHaveBeenCalledTimes(1) + }) + + it('should surface moved errors with empty canonical project route fields', async () => { + const movedError = makeMovedException({ path: '/project/john/demo', owner: '', name: 'demo' }) + mockedPatch.mockRejectedValueOnce(movedError) + + await expect(updateProject('John', 'Demo', { displayName: 'Demo' })).rejects.toBe(movedError) + + expect(mockedPatch).toHaveBeenCalledTimes(1) + }) +}) + +describe('isProjectNameTaken', () => { + let mockedGet: ReturnType + + beforeEach(() => { + mockedGet = vi.spyOn(client, 'get') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should return true for a canonical project route hit', async () => { + mockedGet.mockResolvedValueOnce({ + owner: 'john', + name: 'demo' + }) + + await expect(isProjectNameTaken('john', 'demo')).resolves.toBe(true) + }) + + it('should return true when the canonical project route only differs by casing', async () => { + mockedGet.mockResolvedValueOnce({ + owner: 'john', + name: 'demo' + }) + + await expect(isProjectNameTaken('John', 'Demo')).resolves.toBe(true) + }) + + it('should return false for an alias hit that resolves to another canonical project name', async () => { + mockedGet.mockResolvedValueOnce({ + owner: 'john', + name: 'renamed-demo' + }) + + await expect(isProjectNameTaken('john', 'demo')).resolves.toBe(false) + }) + + it('should return false when the project route is not found', async () => { + mockedGet.mockRejectedValueOnce( + new ApiException(ApiExceptionCode.errorNotFound, 'Not found', { + req: new Request('https://api.example.com/project/john/demo', { method: 'GET' }) + }) + ) + + await expect(isProjectNameTaken('john', 'demo')).resolves.toBe(false) + }) +}) diff --git a/spx-gui/src/apis/project.ts b/spx-gui/src/apis/project.ts index feca9ce3c..068594fa5 100644 --- a/spx-gui/src/apis/project.ts +++ b/spx-gui/src/apis/project.ts @@ -1,7 +1,7 @@ import dayjs from 'dayjs' import type { FileCollection, ByPage, PaginationParams, Perspective, ArtStyle } from './common' import { client, Visibility, ownerAll, timeStringify } from './common' -import { ApiException, ApiExceptionCode } from './common/exception' +import { ApiException, ApiExceptionCode, type MovedResourceCanonical } from './common/exception' import { parseProjectReleaseFullName, stringifyProjectReleaseFullName, type ProjectRelease } from './project-release' import type { Prettify } from '@/utils/types' @@ -112,11 +112,31 @@ export type UpdateProjectParams = Prettify< > export function updateProject(owner: string, name: string, params: UpdateProjectParams, signal?: AbortSignal) { + return updateProjectOnce(owner, name, params, signal).catch((err) => { + const movedProjectIdentifier = getMovedProjectIdentifier(err) + if (movedProjectIdentifier == null) throw err + + const { owner: canonicalOwner, name: canonicalName } = movedProjectIdentifier + if (canonicalOwner === owner && canonicalName === name) throw err + + return updateProjectOnce(canonicalOwner, canonicalName, params, signal) + }) +} + +function updateProjectOnce(owner: string, name: string, params: UpdateProjectParams, signal?: AbortSignal) { return client.patch(`/project/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, params, { signal }) as Promise } +function getMovedProjectIdentifier(err: unknown): { owner: string; name: string } | null { + if (!(err instanceof ApiException) || err.code !== ApiExceptionCode.errorResourceMoved) return null + if (err.meta == null || typeof err.meta !== 'object') return null + const { owner, name } = err.meta as Partial + if (!owner || !name) return null + return { owner, name } +} + export function deleteProject(owner: string, name: string) { return client.delete(`/project/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`) as Promise } @@ -161,6 +181,16 @@ export function getProject(owner: string, name: string, signal?: AbortSignal) { }) as Promise } +export async function isProjectNameTaken(owner: string, name: string, signal?: AbortSignal) { + try { + const project = await getProject(owner, name, signal) + return project.owner.toLowerCase() === owner.toLowerCase() && project.name.toLowerCase() === name.toLowerCase() + } catch (e) { + if (e instanceof ApiException && e.code === ApiExceptionCode.errorNotFound) return false + throw e + } +} + export enum ExploreOrder { MostLikes = 'likes', MostRemixes = 'remix', diff --git a/spx-gui/src/apis/user.test.ts b/spx-gui/src/apis/user.test.ts new file mode 100644 index 000000000..7562d4bed --- /dev/null +++ b/spx-gui/src/apis/user.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { client } from './common' +import { ApiException, ApiExceptionCode } from './common/exception' +import { isUsernameTaken } from './user' + +describe('isUsernameTaken', () => { + let mockedGet: ReturnType + + beforeEach(() => { + mockedGet = vi.spyOn(client, 'get') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should return true for a canonical username hit', async () => { + mockedGet.mockResolvedValueOnce({ + username: 'john' + }) + + await expect(isUsernameTaken('john')).resolves.toBe(true) + }) + + it('should return true when the canonical username only differs by casing', async () => { + mockedGet.mockResolvedValueOnce({ + username: 'john' + }) + + await expect(isUsernameTaken('John')).resolves.toBe(true) + }) + + it('should return false for an alias hit that resolves to another canonical username', async () => { + mockedGet.mockResolvedValueOnce({ + username: 'renamed-john' + }) + + await expect(isUsernameTaken('john')).resolves.toBe(false) + }) + + it('should return false when the username is not found', async () => { + mockedGet.mockRejectedValueOnce( + new ApiException(ApiExceptionCode.errorNotFound, 'Not found', { + req: new Request('https://api.example.com/user/john', { method: 'GET' }) + }) + ) + + await expect(isUsernameTaken('john')).resolves.toBe(false) + }) +}) diff --git a/spx-gui/src/apis/user.ts b/spx-gui/src/apis/user.ts index 48bda662e..bfe3df553 100644 --- a/spx-gui/src/apis/user.ts +++ b/spx-gui/src/apis/user.ts @@ -38,6 +38,16 @@ export function getUser(name: string): Promise { return client.get(`/user/${encodeURIComponent(name)}`) as Promise } +export async function isUsernameTaken(username: string) { + try { + const user = await getUser(username) + return user.username.toLowerCase() === username.toLowerCase() + } catch (e) { + if (e instanceof ApiException && e.code === ApiExceptionCode.errorNotFound) return false + throw e + } +} + export function getSignedInUser(): Promise { return client.get(`/user`) as Promise } diff --git a/spx-gui/src/components/community/user/ModifyUsernameModal.vue b/spx-gui/src/components/community/user/ModifyUsernameModal.vue index 2199903f2..93410068b 100644 --- a/spx-gui/src/components/community/user/ModifyUsernameModal.vue +++ b/spx-gui/src/components/community/user/ModifyUsernameModal.vue @@ -9,8 +9,7 @@ import { useForm, type FormValidationResult } from '@/components/ui' -import { ApiException, ApiExceptionCode } from '@/apis/common/exception' -import { getUser } from '@/apis/user' +import { isUsernameTaken } from '@/apis/user' import { useModifySignedInUsername } from '@/stores/user' import { useMessageHandle } from '@/utils/exception' import { useI18n } from '@/utils/i18n' @@ -47,11 +46,7 @@ async function validateUsername(val: string): Promise { zh: '用户名长度超出限制(最多 100 个字符)' }) - const existedUser = await getUser(trimmed).catch((e) => { - if (e instanceof ApiException && e.code === ApiExceptionCode.errorNotFound) return null - throw e - }) - if (existedUser != null) + if (await isUsernameTaken(trimmed)) return t({ en: `Username ${trimmed} already exists`, zh: `用户名 ${trimmed} 已存在` diff --git a/spx-gui/src/components/community/user/index.ts b/spx-gui/src/components/community/user/index.ts index 07fffd7f2..4b454a198 100644 --- a/spx-gui/src/components/community/user/index.ts +++ b/spx-gui/src/components/community/user/index.ts @@ -22,16 +22,12 @@ export function useModifyUsername() { zh: '您将会被登出,需要重新登录才能继续使用。' }, { - en: 'Your profile page URL will change, and existing links to your profile may no longer work.', - zh: '您的个人主页 URL 将会变更,原有主页链接可能无法继续访问。' + en: 'Your profile, project page, and editor URLs will change to use the new username.', + zh: '您的个人主页、项目页和编辑页链接将会切换为新的用户名。' }, { - en: 'Project URLs that include your username, including project pages and editor routes, will also change.', - zh: '包含您用户名的项目链接(包括项目页和编辑页)也会随之变更。' - }, - { - en: 'Existing sharing links to your projects may become invalid.', - zh: '已分享出去的项目链接可能会失效。' + en: 'Existing links should currently redirect to the new address, but may stop pointing to your account or projects if the old username is claimed again in the future.', + zh: '现有链接当前通常会自动跳转到新地址,但如果旧用户名将来被重新占用,这些链接可能不再指向您的账号或项目。' }, { en: 'This operation may take a moment to complete.', diff --git a/spx-gui/src/components/editor/navbar/EditorNavbar.vue b/spx-gui/src/components/editor/navbar/EditorNavbar.vue index 1b6f0184e..d7fb5e4a7 100644 --- a/spx-gui/src/components/editor/navbar/EditorNavbar.vue +++ b/spx-gui/src/components/editor/navbar/EditorNavbar.vue @@ -161,6 +161,7 @@ import { import { useMessageHandle } from '@/utils/exception' import { useI18n, type LocaleMessage } from '@/utils/i18n' import { useNetwork } from '@/utils/network' +import { getProjectEditorRouteParams } from '@/utils/project-route' import { selectFile } from '@/utils/file' import { convertScratchToXbp } from '@/apis/sb2xbp' import { type SpxProject } from '@/models/spx/project' @@ -331,11 +332,7 @@ const handleModifyProjectName = useMessageHandle( if (nextName !== previousName && project.owner != null) { const currentRoute = router.currentRoute.value router.replace({ - params: { - ...currentRoute.params, - ownerNameInput: project.owner, - projectNameInput: nextName - }, + params: getProjectEditorRouteParams(currentRoute.params, { owner: project.owner, name: nextName }), query: currentRoute.query }) } diff --git a/spx-gui/src/components/project/ProjectCreateModal.vue b/spx-gui/src/components/project/ProjectCreateModal.vue index 1102e9b0a..b28b2381b 100644 --- a/spx-gui/src/components/project/ProjectCreateModal.vue +++ b/spx-gui/src/components/project/ProjectCreateModal.vue @@ -48,12 +48,11 @@ import { useForm, type FormValidationResult } from '@/components/ui' -import { getProject, addProject, ProjectType, Visibility, parseRemixSource } from '@/apis/project' +import { addProject, isProjectNameTaken, ProjectType, Visibility, parseRemixSource } from '@/apis/project' import { useI18n } from '@/utils/i18n' import { useMessageHandle } from '@/utils/exception' import { untilLoaded } from '@/utils/query' import { useSignedInStateQuery } from '@/stores/user' -import { ApiException, ApiExceptionCode } from '@/apis/common/exception' import { cloudHelpers } from '@/models/common/cloud' import { xbpHelpers } from '@/models/common/xbp' import { SpxProject } from '@/models/spx/project' @@ -141,11 +140,7 @@ async function validateName(name: string): Promise { // check naming conflict const signedInState = await untilLoaded(signedInStateQuery) if (!signedInState.isSignedIn) throw new Error('login required') - const existedProject = await getProject(signedInState.user.username, name).catch((e) => { - if (e instanceof ApiException && e.code === ApiExceptionCode.errorNotFound) return null - throw e - }) - if (existedProject != null) + if (await isProjectNameTaken(signedInState.user.username, name)) return t({ en: `Project ${name} already exists`, zh: `项目 ${name} 已存在` diff --git a/spx-gui/src/components/project/ProjectModifyNameModal.vue b/spx-gui/src/components/project/ProjectModifyNameModal.vue index 6d81a88fe..bb2e97a89 100644 --- a/spx-gui/src/components/project/ProjectModifyNameModal.vue +++ b/spx-gui/src/components/project/ProjectModifyNameModal.vue @@ -9,8 +9,7 @@ import { useForm, type FormValidationResult } from '@/components/ui' -import { ApiException, ApiExceptionCode } from '@/apis/common/exception' -import { getProject, updateProject } from '@/apis/project' +import { isProjectNameTaken, updateProject } from '@/apis/project' import type { SpxProject } from '@/models/spx/project' import { useMessageHandle } from '@/utils/exception' import { useI18n } from '@/utils/i18n' @@ -75,11 +74,7 @@ async function validateName(name: string): Promise { const owner = props.project.owner if (owner == null) throw new Error('Project owner is not loaded') - const existedProject = await getProject(owner, name).catch((e) => { - if (e instanceof ApiException && e.code === ApiExceptionCode.errorNotFound) return null - throw e - }) - if (existedProject != null) + if (await isProjectNameTaken(owner, name)) return t({ en: `Project ${name} already exists`, zh: `项目 ${name} 已存在` diff --git a/spx-gui/src/components/project/index.ts b/spx-gui/src/components/project/index.ts index 0601eb972..dc1ba97cf 100644 --- a/spx-gui/src/components/project/index.ts +++ b/spx-gui/src/components/project/index.ts @@ -86,12 +86,12 @@ export function useModifyProjectName() { tip: { en: 'Changing the project name may have the following impacts.', zh: '修改项目名,可能造成以下影响。' }, items: [ { - en: 'The project page URL will change, and existing links will no longer work.', - zh: '项目页面 URL 将会变更,原有链接将无法访问。' + en: 'The project page and editor URLs will change to use the new project name.', + zh: '项目页和编辑页链接将会切换为新的项目名。' }, { - en: 'Existing sharing links to this project will become invalid.', - zh: '已有的项目分享链接将会失效。' + en: 'Existing links should currently redirect to the new address, but may stop pointing to this project if the old project name is claimed again in the future.', + zh: '现有链接当前通常会自动跳转到新地址,但如果旧项目名将来被重新占用,这些链接可能不再指向当前项目。' }, { en: 'This operation may take a moment to complete.', diff --git a/spx-gui/src/pages/community/project.vue b/spx-gui/src/pages/community/project.vue index d07da1f98..51523a910 100644 --- a/spx-gui/src/pages/community/project.vue +++ b/spx-gui/src/pages/community/project.vue @@ -1,7 +1,8 @@