chore: remove frontend changes

This commit is contained in:
Novice 2025-12-30 10:19:40 +08:00
parent f55faae31b
commit 56c8221b3f
No known key found for this signature in database
GPG Key ID: EE3F68E3105DAAAB
4921 changed files with 308906 additions and 230467 deletions

View File

@ -73,3 +73,6 @@ NEXT_PUBLIC_MAX_TREE_DEPTH=50
# The API key of amplitude
NEXT_PUBLIC_AMPLITUDE_API_KEY=
# number of concurrency
NEXT_PUBLIC_BATCH_CONCURRENCY=5

10
web/.gitignore vendored
View File

@ -54,3 +54,13 @@ package-lock.json
# mise
mise.toml
# PWA generated files
public/sw.js
public/sw.js.map
public/workbox-*.js
public/workbox-*.js.map
public/fallback-*.js
.vscode/settings.json
.vscode/mcp.json

View File

@ -1,144 +0,0 @@
{
"plugins": [
"unicorn",
"typescript",
"oxc"
],
"categories": {},
"rules": {
"for-direction": "error",
"no-async-promise-executor": "error",
"no-caller": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "warn",
"no-const-assign": "warn",
"no-constant-binary-expression": "error",
"no-constant-condition": "warn",
"no-control-regex": "warn",
"no-debugger": "warn",
"no-delete-var": "warn",
"no-dupe-class-members": "warn",
"no-dupe-else-if": "warn",
"no-dupe-keys": "warn",
"no-duplicate-case": "warn",
"no-empty-character-class": "warn",
"no-empty-pattern": "warn",
"no-empty-static-block": "warn",
"no-eval": "warn",
"no-ex-assign": "warn",
"no-extra-boolean-cast": "warn",
"no-func-assign": "warn",
"no-global-assign": "warn",
"no-import-assign": "warn",
"no-invalid-regexp": "warn",
"no-irregular-whitespace": "warn",
"no-loss-of-precision": "warn",
"no-new-native-nonconstructor": "warn",
"no-nonoctal-decimal-escape": "warn",
"no-obj-calls": "warn",
"no-self-assign": "warn",
"no-setter-return": "warn",
"no-shadow-restricted-names": "warn",
"no-sparse-arrays": "warn",
"no-this-before-super": "warn",
"no-unassigned-vars": "warn",
"no-unsafe-finally": "warn",
"no-unsafe-negation": "warn",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "warn",
"no-unused-private-class-members": "warn",
"no-unused-vars": "warn",
"no-useless-backreference": "warn",
"no-useless-catch": "error",
"no-useless-escape": "warn",
"no-useless-rename": "warn",
"no-with": "warn",
"require-yield": "warn",
"use-isnan": "warn",
"valid-typeof": "warn",
"oxc/bad-array-method-on-arguments": "warn",
"oxc/bad-char-at-comparison": "warn",
"oxc/bad-comparison-sequence": "warn",
"oxc/bad-min-max-func": "warn",
"oxc/bad-object-literal-comparison": "warn",
"oxc/bad-replace-all-arg": "warn",
"oxc/const-comparisons": "warn",
"oxc/double-comparisons": "warn",
"oxc/erasing-op": "warn",
"oxc/missing-throw": "warn",
"oxc/number-arg-out-of-range": "warn",
"oxc/only-used-in-recursion": "warn",
"oxc/uninvoked-array-callback": "warn",
"typescript/await-thenable": "warn",
"typescript/no-array-delete": "warn",
"typescript/no-base-to-string": "warn",
"typescript/no-confusing-void-expression": "warn",
"typescript/no-duplicate-enum-values": "warn",
"typescript/no-duplicate-type-constituents": "warn",
"typescript/no-extra-non-null-assertion": "warn",
"typescript/no-floating-promises": "warn",
"typescript/no-for-in-array": "warn",
"typescript/no-implied-eval": "warn",
"typescript/no-meaningless-void-operator": "warn",
"typescript/no-misused-new": "warn",
"typescript/no-misused-spread": "warn",
"typescript/no-non-null-asserted-optional-chain": "warn",
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-this-alias": "warn",
"typescript/no-unnecessary-parameter-property-assignment": "warn",
"typescript/no-unsafe-declaration-merging": "warn",
"typescript/no-unsafe-unary-minus": "warn",
"typescript/no-useless-empty-export": "warn",
"typescript/no-wrapper-object-types": "warn",
"typescript/prefer-as-const": "warn",
"typescript/require-array-sort-compare": "warn",
"typescript/restrict-template-expressions": "warn",
"typescript/triple-slash-reference": "warn",
"typescript/unbound-method": "warn",
"unicorn/no-await-in-promise-methods": "warn",
"unicorn/no-empty-file": "warn",
"unicorn/no-invalid-fetch-options": "warn",
"unicorn/no-invalid-remove-event-listener": "warn",
"unicorn/no-new-array": "warn",
"unicorn/no-single-promise-in-promise-methods": "warn",
"unicorn/no-thenable": "warn",
"unicorn/no-unnecessary-await": "warn",
"unicorn/no-useless-fallback-in-spread": "warn",
"unicorn/no-useless-length-check": "warn",
"unicorn/no-useless-spread": "warn",
"unicorn/prefer-set-size": "warn",
"unicorn/prefer-string-starts-ends-with": "warn"
},
"settings": {
"jsx-a11y": {
"polymorphicPropName": null,
"components": {},
"attributes": {}
},
"next": {
"rootDir": []
},
"react": {
"formComponents": [],
"linkComponents": []
},
"jsdoc": {
"ignorePrivate": false,
"ignoreInternal": false,
"ignoreReplacesDocs": true,
"overrideReplacesDocs": true,
"augmentsExtendsReplacesDocs": false,
"implementsReplacesDocs": false,
"exemptDestructuredRootsFromChecks": false,
"tagNamePreference": {}
}
},
"env": {
"builtin": true
},
"globals": {},
"ignorePatterns": [
"**/*.js"
]
}

View File

@ -1,8 +1,8 @@
import type { Preview } from '@storybook/react'
import { withThemeByDataAttribute } from '@storybook/addon-themes'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import I18N from '../app/components/i18n'
import { ToastProvider } from '../app/components/base/toast'
import I18N from '../app/components/i18n'
import '../app/styles/globals.css'
import '../app/styles/markdown.scss'

View File

@ -1,6 +1,6 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { useStore } from '@tanstack/react-form'
import { useState } from 'react'
import { useAppForm } from '@/app/components/base/form'
type UseAppFormOptions = Parameters<typeof useAppForm>[0]
@ -49,7 +49,12 @@ export const FormStoryWrapper = ({
<aside className="w-full max-w-sm rounded-xl border border-divider-subtle bg-components-panel-bg p-4 text-xs text-text-secondary shadow-sm">
<div className="flex items-center justify-between text-[11px] uppercase tracking-wide text-text-tertiary">
<span>Form State</span>
<span>{submitCount} submit{submitCount === 1 ? '' : 's'}</span>
<span>
{submitCount}
{' '}
submit
{submitCount === 1 ? '' : 's'}
</span>
</div>
<dl className="mt-2 space-y-1">
<div className="flex items-center justify-between rounded-md bg-components-button-tertiary-bg px-2 py-1">

View File

@ -1,7 +1,6 @@
{
"recommendations": [
"bradlc.vscode-tailwindcss",
"firsttris.vscode-jest-runner",
"kisstkondoros.vscode-codemetrics"
]
}

View File

@ -1,15 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
}
]
}
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
}
]
}

View File

@ -1,26 +1,50 @@
{
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": true,
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
"eslint.format.enable": true,
"[python]": {
"editor.formatOnType": true
},
"[html]": {
"editor.defaultFormatter": "vscode.html-language-features"
},
"[typescriptreact]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[javascriptreact]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"npm.packageManager": "pnpm"
// Silent the stylistic rules in your IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true },
{ "rule": "*-spaces", "severity": "off", "fixable": true },
{ "rule": "*-order", "severity": "off", "fixable": true },
{ "rule": "*-dangle", "severity": "off", "fixable": true },
{ "rule": "*-newline", "severity": "off", "fixable": true },
{ "rule": "*quotes", "severity": "off", "fixable": true },
{ "rule": "*semi", "severity": "off", "fixable": true }
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml",
"toml",
"xml",
"gql",
"graphql",
"astro",
"svelte",
"css",
"less",
"scss",
"pcss",
"postcss"
]
}

View File

@ -99,14 +99,14 @@ If your IDE is VSCode, rename `web/.vscode/settings.example.json` to `web/.vscod
## Test
We use [Jest](https://jestjs.io/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) for Unit Testing.
We use [Vitest](https://vitest.dev/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) for Unit Testing.
**📖 Complete Testing Guide**: See [web/testing/testing.md](./testing/testing.md) for detailed testing specifications, best practices, and examples.
Run test:
```bash
pnpm run test
pnpm test
```
### Example Code

View File

View File

@ -1,8 +1,40 @@
import { merge, noop } from 'lodash-es'
import { defaultPlan } from '@/app/components/billing/config'
import { baseProviderContextValue } from '@/context/provider-context'
import type { ProviderContextState } from '@/context/provider-context'
import type { Plan, UsagePlanInfo } from '@/app/components/billing/type'
import type { ProviderContextState } from '@/context/provider-context'
import { merge, noop } from 'es-toolkit/compat'
import { defaultPlan } from '@/app/components/billing/config'
// Avoid being mocked in tests
export const baseProviderContextValue: ProviderContextState = {
modelProviders: [],
refreshModelProviders: noop,
textGenerationModelList: [],
supportRetrievalMethods: [],
isAPIKeySet: true,
plan: defaultPlan,
isFetchedPlan: false,
enableBilling: false,
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,
datasetOperatorEnabled: false,
enableEducationPlan: false,
isEducationWorkspace: false,
isEducationAccount: false,
allowRefreshEducationVerify: false,
educationAccountExpireAt: null,
isLoadingEducationAccountInfo: false,
isFetchingEducationAccountInfo: false,
webappCopyrightEnabled: false,
licenseLimit: {
workspace_members: {
size: 0,
limit: 0,
},
},
refreshLicenseLimit: noop,
isAllowTransferWorkspace: false,
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
}
export const createMockProviderContextValue = (overrides: Partial<ProviderContextState> = {}): ProviderContextState => {
const merged = merge({}, baseProviderContextValue, overrides)

View File

@ -1,40 +0,0 @@
/**
* Shared mock for react-i18next
*
* Jest automatically uses this mock when react-i18next is imported in tests.
* The default behavior returns the translation key as-is, which is suitable
* for most test scenarios.
*
* For tests that need custom translations, you can override with jest.mock():
*
* @example
* jest.mock('react-i18next', () => ({
* useTranslation: () => ({
* t: (key: string) => {
* if (key === 'some.key') return 'Custom translation'
* return key
* },
* }),
* }))
*/
export const useTranslation = () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.returnObjects)
return [`${key}-feature-1`, `${key}-feature-2`]
if (options)
return `${key}:${JSON.stringify(options)}`
return key
},
i18n: {
language: 'en',
changeLanguage: jest.fn(),
},
})
export const Trans = ({ children }: { children?: React.ReactNode }) => children
export const initReactI18next = {
type: '3rdParty',
init: jest.fn(),
}

View File

@ -1,11 +1,9 @@
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import { transpile } from 'typescript'
// Mock functions to simulate the check-i18n functionality
const vm = require('node:vm')
const transpile = require('typescript').transpile
describe('check-i18n script functionality', () => {
describe('i18n:check script functionality', () => {
const testDir = path.join(__dirname, '../i18n-test')
const testEnDir = path.join(testDir, 'en-US')
const testZhDir = path.join(testDir, 'zh-Hans')
@ -33,8 +31,7 @@ describe('check-i18n script functionality', () => {
const filePath = path.join(folderPath, file)
const fileName = file.replace(/\.[^/.]+$/, '')
const camelCaseFileName = fileName.replace(/[-_](.)/g, (_, c) =>
c.toUpperCase(),
)
c.toUpperCase())
try {
const content = fs.readFileSync(filePath, 'utf8')
@ -617,9 +614,10 @@ export default translation
// Check if this line ends the value (ends with quote and comma/no comma)
if ((trimmed.endsWith('\',') || trimmed.endsWith('",') || trimmed.endsWith('`,')
|| trimmed.endsWith('\'') || trimmed.endsWith('"') || trimmed.endsWith('`'))
&& !trimmed.startsWith('//'))
|| trimmed.endsWith('\'') || trimmed.endsWith('"') || trimmed.endsWith('`'))
&& !trimmed.startsWith('//')) {
break
}
}
else {
break

View File

@ -15,19 +15,19 @@ describe('Description Validation Logic', () => {
}
describe('Backend Validation Function', () => {
test('allows description within 400 characters', () => {
it('allows description within 400 characters', () => {
const validDescription = 'x'.repeat(400)
expect(() => validateDescriptionLength(validDescription)).not.toThrow()
expect(validateDescriptionLength(validDescription)).toBe(validDescription)
})
test('allows empty description', () => {
it('allows empty description', () => {
expect(() => validateDescriptionLength('')).not.toThrow()
expect(() => validateDescriptionLength(null)).not.toThrow()
expect(() => validateDescriptionLength(undefined)).not.toThrow()
})
test('rejects description exceeding 400 characters', () => {
it('rejects description exceeding 400 characters', () => {
const invalidDescription = 'x'.repeat(401)
expect(() => validateDescriptionLength(invalidDescription)).toThrow(
'Description cannot exceed 400 characters.',
@ -36,7 +36,7 @@ describe('Description Validation Logic', () => {
})
describe('Backend Validation Consistency', () => {
test('App and Dataset have consistent validation limits', () => {
it('App and Dataset have consistent validation limits', () => {
const maxLength = 400
const validDescription = 'x'.repeat(maxLength)
const invalidDescription = 'x'.repeat(maxLength + 1)
@ -50,7 +50,7 @@ describe('Description Validation Logic', () => {
expect(() => validateDescriptionLength(invalidDescription)).toThrow()
})
test('validation error messages are consistent', () => {
it('validation error messages are consistent', () => {
const expectedErrorMessage = 'Description cannot exceed 400 characters.'
// This would be the error message from both App and Dataset backend validation
@ -78,7 +78,7 @@ describe('Description Validation Logic', () => {
]
testCases.forEach(({ length, shouldPass, description }) => {
test(`handles ${description} correctly`, () => {
it(`handles ${description} correctly`, () => {
const testDescription = length > 0 ? 'x'.repeat(length) : ''
expect(testDescription.length).toBe(length)

View File

@ -1,3 +1,4 @@
import type { Mock } from 'vitest'
/**
* Document Detail Navigation Fix Verification Test
*
@ -10,36 +11,36 @@ import { useRouter } from 'next/navigation'
import { useDocumentDetail, useDocumentMetadata } from '@/service/knowledge/use-document'
// Mock Next.js router
const mockPush = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: jest.fn(() => ({
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: vi.fn(() => ({
push: mockPush,
})),
}))
// Mock the document service hooks
jest.mock('@/service/knowledge/use-document', () => ({
useDocumentDetail: jest.fn(),
useDocumentMetadata: jest.fn(),
useInvalidDocumentList: jest.fn(() => jest.fn()),
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentDetail: vi.fn(),
useDocumentMetadata: vi.fn(),
useInvalidDocumentList: vi.fn(() => vi.fn()),
}))
// Mock other dependencies
jest.mock('@/context/dataset-detail', () => ({
useDatasetDetailContext: jest.fn(() => [null]),
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContext: vi.fn(() => [null]),
}))
jest.mock('@/service/use-base', () => ({
useInvalid: jest.fn(() => jest.fn()),
vi.mock('@/service/use-base', () => ({
useInvalid: vi.fn(() => vi.fn()),
}))
jest.mock('@/service/knowledge/use-segment', () => ({
useSegmentListKey: jest.fn(),
useChildSegmentListKey: jest.fn(),
vi.mock('@/service/knowledge/use-segment', () => ({
useSegmentListKey: vi.fn(),
useChildSegmentListKey: vi.fn(),
}))
// Create a minimal version of the DocumentDetail component that includes our fix
const DocumentDetailWithFix = ({ datasetId, documentId }: { datasetId: string; documentId: string }) => {
const DocumentDetailWithFix = ({ datasetId, documentId }: { datasetId: string, documentId: string }) => {
const router = useRouter()
// This is the FIXED implementation from detail/index.tsx
@ -58,7 +59,12 @@ const DocumentDetailWithFix = ({ datasetId, documentId }: { datasetId: string; d
Back to Documents
</button>
<div data-testid="document-info">
Dataset: {datasetId}, Document: {documentId}
Dataset:
{' '}
{datasetId}
, Document:
{' '}
{documentId}
</div>
</div>
)
@ -66,10 +72,10 @@ const DocumentDetailWithFix = ({ datasetId, documentId }: { datasetId: string; d
describe('Document Detail Navigation Fix Verification', () => {
beforeEach(() => {
jest.clearAllMocks()
vi.clearAllMocks()
// Mock successful API responses
;(useDocumentDetail as jest.Mock).mockReturnValue({
;(useDocumentDetail as Mock).mockReturnValue({
data: {
id: 'doc-123',
name: 'Test Document',
@ -80,14 +86,14 @@ describe('Document Detail Navigation Fix Verification', () => {
error: null,
})
;(useDocumentMetadata as jest.Mock).mockReturnValue({
;(useDocumentMetadata as Mock).mockReturnValue({
data: null,
error: null,
})
})
describe('Query Parameter Preservation', () => {
test('preserves pagination state (page 3, limit 25)', () => {
it('preserves pagination state (page 3, limit 25)', () => {
// Simulate user coming from page 3 with 25 items per page
Object.defineProperty(window, 'location', {
value: {
@ -107,7 +113,7 @@ describe('Document Detail Navigation Fix Verification', () => {
console.log('✅ Pagination state preserved: page=3&limit=25')
})
test('preserves search keyword and filters', () => {
it('preserves search keyword and filters', () => {
// Simulate user with search and filters applied
Object.defineProperty(window, 'location', {
value: {
@ -126,7 +132,7 @@ describe('Document Detail Navigation Fix Verification', () => {
console.log('✅ Search and filters preserved')
})
test('handles complex query parameters with special characters', () => {
it('handles complex query parameters with special characters', () => {
// Test with complex query string including encoded characters
Object.defineProperty(window, 'location', {
value: {
@ -151,7 +157,7 @@ describe('Document Detail Navigation Fix Verification', () => {
console.log('✅ Complex query parameters handled:', expectedCall)
})
test('handles empty query parameters gracefully', () => {
it('handles empty query parameters gracefully', () => {
// No query parameters in URL
Object.defineProperty(window, 'location', {
value: {
@ -172,7 +178,7 @@ describe('Document Detail Navigation Fix Verification', () => {
})
describe('Different Dataset IDs', () => {
test('works with different dataset identifiers', () => {
it('works with different dataset identifiers', () => {
Object.defineProperty(window, 'location', {
value: {
search: '?page=5&limit=10',
@ -192,7 +198,7 @@ describe('Document Detail Navigation Fix Verification', () => {
})
describe('Real User Scenarios', () => {
test('scenario: user searches, goes to page 3, views document, clicks back', () => {
it('scenario: user searches, goes to page 3, views document, clicks back', () => {
// User searched for "API" and navigated to page 3
Object.defineProperty(window, 'location', {
value: {
@ -212,7 +218,7 @@ describe('Document Detail Navigation Fix Verification', () => {
console.log('✅ Real user scenario: search + pagination preserved')
})
test('scenario: user applies multiple filters, goes to document, returns', () => {
it('scenario: user applies multiple filters, goes to document, returns', () => {
// User has applied multiple filters and is on page 2
Object.defineProperty(window, 'location', {
value: {
@ -233,7 +239,7 @@ describe('Document Detail Navigation Fix Verification', () => {
})
describe('Error Handling and Edge Cases', () => {
test('handles malformed query parameters gracefully', () => {
it('handles malformed query parameters gracefully', () => {
// Test with potentially problematic query string
Object.defineProperty(window, 'location', {
value: {
@ -257,7 +263,7 @@ describe('Document Detail Navigation Fix Verification', () => {
console.log('✅ Malformed parameters handled gracefully:', navigationPath)
})
test('handles very long query strings', () => {
it('handles very long query strings', () => {
// Test with a very long query string
const longKeyword = 'a'.repeat(1000)
Object.defineProperty(window, 'location', {
@ -280,7 +286,7 @@ describe('Document Detail Navigation Fix Verification', () => {
})
describe('Performance Verification', () => {
test('navigation function executes quickly', () => {
it('navigation function executes quickly', () => {
Object.defineProperty(window, 'location', {
value: {
search: '?page=1&limit=10&keyword=test',

View File

@ -46,32 +46,32 @@ describe('Document List Sorting', () => {
})
}
test('sorts by name descending (default for UI consistency)', () => {
it('sorts by name descending (default for UI consistency)', () => {
const sorted = sortDocuments(mockDocuments, 'name', 'desc')
expect(sorted.map(doc => doc.name)).toEqual(['Gamma.docx', 'Beta.pdf', 'Alpha.txt'])
})
test('sorts by name ascending (after toggle)', () => {
it('sorts by name ascending (after toggle)', () => {
const sorted = sortDocuments(mockDocuments, 'name', 'asc')
expect(sorted.map(doc => doc.name)).toEqual(['Alpha.txt', 'Beta.pdf', 'Gamma.docx'])
})
test('sorts by word_count descending', () => {
it('sorts by word_count descending', () => {
const sorted = sortDocuments(mockDocuments, 'word_count', 'desc')
expect(sorted.map(doc => doc.word_count)).toEqual([800, 500, 200])
})
test('sorts by hit_count descending', () => {
it('sorts by hit_count descending', () => {
const sorted = sortDocuments(mockDocuments, 'hit_count', 'desc')
expect(sorted.map(doc => doc.hit_count)).toEqual([25, 10, 5])
})
test('sorts by created_at descending (newest first)', () => {
it('sorts by created_at descending (newest first)', () => {
const sorted = sortDocuments(mockDocuments, 'created_at', 'desc')
expect(sorted.map(doc => doc.created_at)).toEqual([1699123500, 1699123456, 1699123400])
})
test('handles empty values correctly', () => {
it('handles empty values correctly', () => {
const docsWithEmpty = [
{ id: '1', name: 'Test', word_count: 100, hit_count: 5, created_at: 1699123456 },
{ id: '2', name: 'Empty', word_count: 0, hit_count: 0, created_at: 1699123400 },

View File

@ -1,19 +1,20 @@
import React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import MailAndPasswordAuth from '@/app/(shareLayout)/webapp-signin/components/mail-and-password-auth'
import CheckCode from '@/app/(shareLayout)/webapp-signin/check-code/page'
import MailAndPasswordAuth from '@/app/(shareLayout)/webapp-signin/components/mail-and-password-auth'
const replaceMock = jest.fn()
const backMock = jest.fn()
const replaceMock = vi.fn()
const backMock = vi.fn()
const useSearchParamsMock = vi.fn(() => new URLSearchParams())
jest.mock('next/navigation', () => ({
usePathname: jest.fn(() => '/chatbot/test-app'),
useRouter: jest.fn(() => ({
vi.mock('next/navigation', () => ({
usePathname: vi.fn(() => '/chatbot/test-app'),
useRouter: vi.fn(() => ({
replace: replaceMock,
back: backMock,
})),
useSearchParams: jest.fn(),
useSearchParams: () => useSearchParamsMock(),
}))
const mockStoreState = {
@ -21,59 +22,55 @@ const mockStoreState = {
shareCode: 'test-app',
}
const useWebAppStoreMock = jest.fn((selector?: (state: typeof mockStoreState) => any) => {
const useWebAppStoreMock = vi.fn((selector?: (state: typeof mockStoreState) => any) => {
return selector ? selector(mockStoreState) : mockStoreState
})
jest.mock('@/context/web-app-context', () => ({
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: (selector?: (state: typeof mockStoreState) => any) => useWebAppStoreMock(selector),
}))
const webAppLoginMock = jest.fn()
const webAppEmailLoginWithCodeMock = jest.fn()
const sendWebAppEMailLoginCodeMock = jest.fn()
const webAppLoginMock = vi.fn()
const webAppEmailLoginWithCodeMock = vi.fn()
const sendWebAppEMailLoginCodeMock = vi.fn()
jest.mock('@/service/common', () => ({
vi.mock('@/service/common', () => ({
webAppLogin: (...args: any[]) => webAppLoginMock(...args),
webAppEmailLoginWithCode: (...args: any[]) => webAppEmailLoginWithCodeMock(...args),
sendWebAppEMailLoginCode: (...args: any[]) => sendWebAppEMailLoginCodeMock(...args),
}))
const fetchAccessTokenMock = jest.fn()
const fetchAccessTokenMock = vi.fn()
jest.mock('@/service/share', () => ({
vi.mock('@/service/share', () => ({
fetchAccessToken: (...args: any[]) => fetchAccessTokenMock(...args),
}))
const setWebAppAccessTokenMock = jest.fn()
const setWebAppPassportMock = jest.fn()
const setWebAppAccessTokenMock = vi.fn()
const setWebAppPassportMock = vi.fn()
jest.mock('@/service/webapp-auth', () => ({
vi.mock('@/service/webapp-auth', () => ({
setWebAppAccessToken: (...args: any[]) => setWebAppAccessTokenMock(...args),
setWebAppPassport: (...args: any[]) => setWebAppPassportMock(...args),
webAppLogout: jest.fn(),
webAppLogout: vi.fn(),
}))
jest.mock('@/app/components/signin/countdown', () => () => <div data-testid="countdown" />)
vi.mock('@/app/components/signin/countdown', () => ({ default: () => <div data-testid="countdown" /> }))
jest.mock('@remixicon/react', () => ({
vi.mock('@remixicon/react', () => ({
RiMailSendFill: () => <div data-testid="mail-icon" />,
RiArrowLeftLine: () => <div data-testid="arrow-icon" />,
}))
const { useSearchParams } = jest.requireMock('next/navigation') as {
useSearchParams: jest.Mock
}
beforeEach(() => {
jest.clearAllMocks()
vi.clearAllMocks()
})
describe('embedded user id propagation in authentication flows', () => {
it('passes embedded user id when logging in with email and password', async () => {
const params = new URLSearchParams()
params.set('redirect_url', encodeURIComponent('/chatbot/test-app'))
useSearchParams.mockReturnValue(params)
useSearchParamsMock.mockReturnValue(params)
webAppLoginMock.mockResolvedValue({ result: 'success', data: { access_token: 'login-token' } })
fetchAccessTokenMock.mockResolvedValue({ access_token: 'passport-token' })
@ -100,7 +97,7 @@ describe('embedded user id propagation in authentication flows', () => {
params.set('redirect_url', encodeURIComponent('/chatbot/test-app'))
params.set('email', encodeURIComponent('user@example.com'))
params.set('token', encodeURIComponent('token-abc'))
useSearchParams.mockReturnValue(params)
useSearchParamsMock.mockReturnValue(params)
webAppEmailLoginWithCodeMock.mockResolvedValue({ result: 'success', data: { access_token: 'code-token' } })
fetchAccessTokenMock.mockResolvedValue({ access_token: 'passport-token' })

View File

@ -1,42 +1,42 @@
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context'
jest.mock('next/navigation', () => ({
usePathname: jest.fn(() => '/chatbot/sample-app'),
useSearchParams: jest.fn(() => {
import { AccessMode } from '@/models/access-control'
vi.mock('next/navigation', () => ({
usePathname: vi.fn(() => '/chatbot/sample-app'),
useSearchParams: vi.fn(() => {
const params = new URLSearchParams()
return params
}),
}))
jest.mock('@/service/use-share', () => {
const { AccessMode } = jest.requireActual('@/models/access-control')
return {
useGetWebAppAccessModeByCode: jest.fn(() => ({
isLoading: false,
data: { accessMode: AccessMode.PUBLIC },
})),
}
})
jest.mock('@/app/components/base/chat/utils', () => ({
getProcessedSystemVariablesFromUrlParams: jest.fn(),
vi.mock('@/service/use-share', () => ({
useGetWebAppAccessModeByCode: vi.fn(() => ({
isLoading: false,
data: { accessMode: AccessMode.PUBLIC },
})),
}))
const { getProcessedSystemVariablesFromUrlParams: mockGetProcessedSystemVariablesFromUrlParams }
= jest.requireMock('@/app/components/base/chat/utils') as {
getProcessedSystemVariablesFromUrlParams: jest.Mock
}
// Store the mock implementation in a way that survives hoisting
const mockGetProcessedSystemVariablesFromUrlParams = vi.fn()
jest.mock('@/context/global-public-context', () => {
const mockGlobalStoreState = {
vi.mock('@/app/components/base/chat/utils', () => ({
getProcessedSystemVariablesFromUrlParams: (...args: any[]) => mockGetProcessedSystemVariablesFromUrlParams(...args),
}))
// Use vi.hoisted to define mock state before vi.mock hoisting
const { mockGlobalStoreState } = vi.hoisted(() => ({
mockGlobalStoreState: {
isGlobalPending: false,
setIsGlobalPending: jest.fn(),
setIsGlobalPending: vi.fn(),
systemFeatures: {},
setSystemFeatures: jest.fn(),
}
setSystemFeatures: vi.fn(),
},
}))
vi.mock('@/context/global-public-context', () => {
const useGlobalPublicStore = Object.assign(
(selector?: (state: typeof mockGlobalStoreState) => any) =>
selector ? selector(mockGlobalStoreState) : mockGlobalStoreState,
@ -56,21 +56,6 @@ jest.mock('@/context/global-public-context', () => {
}
})
const {
useGlobalPublicStore: useGlobalPublicStoreMock,
} = jest.requireMock('@/context/global-public-context') as {
useGlobalPublicStore: ((selector?: (state: any) => any) => any) & {
setState: (updater: any) => void
__mockState: {
isGlobalPending: boolean
setIsGlobalPending: jest.Mock
systemFeatures: Record<string, unknown>
setSystemFeatures: jest.Mock
}
}
}
const mockGlobalStoreState = useGlobalPublicStoreMock.__mockState
const TestConsumer = () => {
const embeddedUserId = useWebAppStore(state => state.embeddedUserId)
const embeddedConversationId = useWebAppStore(state => state.embeddedConversationId)

View File

@ -1,10 +1,9 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import CommandSelector from '../../app/components/goto-anything/command-selector'
import type { ActionItem } from '../../app/components/goto-anything/actions/types'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import CommandSelector from '../../app/components/goto-anything/command-selector'
jest.mock('cmdk', () => ({
vi.mock('cmdk', () => ({
Command: {
Group: ({ children, className }: any) => <div className={className}>{children}</div>,
Item: ({ children, onSelect, value, className }: any) => (
@ -27,36 +26,36 @@ describe('CommandSelector', () => {
shortcut: '@app',
title: 'Search Applications',
description: 'Search apps',
search: jest.fn(),
search: vi.fn(),
},
knowledge: {
key: '@knowledge',
shortcut: '@kb',
title: 'Search Knowledge',
description: 'Search knowledge bases',
search: jest.fn(),
search: vi.fn(),
},
plugin: {
key: '@plugin',
shortcut: '@plugin',
title: 'Search Plugins',
description: 'Search plugins',
search: jest.fn(),
search: vi.fn(),
},
node: {
key: '@node',
shortcut: '@node',
title: 'Search Nodes',
description: 'Search workflow nodes',
search: jest.fn(),
search: vi.fn(),
},
}
const mockOnCommandSelect = jest.fn()
const mockOnCommandValueChange = jest.fn()
const mockOnCommandSelect = vi.fn()
const mockOnCommandValueChange = vi.fn()
beforeEach(() => {
jest.clearAllMocks()
vi.clearAllMocks()
})
describe('Basic Rendering', () => {

View File

@ -1,16 +1,17 @@
import type { Mock } from 'vitest'
import type { ActionItem } from '../../app/components/goto-anything/actions/types'
// Mock the entire actions module to avoid import issues
jest.mock('../../app/components/goto-anything/actions', () => ({
matchAction: jest.fn(),
}))
jest.mock('../../app/components/goto-anything/actions/commands/registry')
// Import after mocking to get mocked version
import { matchAction } from '../../app/components/goto-anything/actions'
import { slashCommandRegistry } from '../../app/components/goto-anything/actions/commands/registry'
// Mock the entire actions module to avoid import issues
vi.mock('../../app/components/goto-anything/actions', () => ({
matchAction: vi.fn(),
}))
vi.mock('../../app/components/goto-anything/actions/commands/registry')
// Implement the actual matchAction logic for testing
const actualMatchAction = (query: string, actions: Record<string, ActionItem>) => {
const result = Object.values(actions).find((action) => {
@ -39,7 +40,7 @@ const actualMatchAction = (query: string, actions: Record<string, ActionItem>) =
}
// Replace mock with actual implementation
;(matchAction as jest.Mock).mockImplementation(actualMatchAction)
;(matchAction as Mock).mockImplementation(actualMatchAction)
describe('matchAction Logic', () => {
const mockActions: Record<string, ActionItem> = {
@ -48,27 +49,27 @@ describe('matchAction Logic', () => {
shortcut: '@a',
title: 'Search Applications',
description: 'Search apps',
search: jest.fn(),
search: vi.fn(),
},
knowledge: {
key: '@knowledge',
shortcut: '@kb',
title: 'Search Knowledge',
description: 'Search knowledge bases',
search: jest.fn(),
search: vi.fn(),
},
slash: {
key: '/',
shortcut: '/',
title: 'Commands',
description: 'Execute commands',
search: jest.fn(),
search: vi.fn(),
},
}
beforeEach(() => {
jest.clearAllMocks()
;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([
vi.clearAllMocks()
;(slashCommandRegistry.getAllCommands as Mock).mockReturnValue([
{ name: 'docs', mode: 'direct' },
{ name: 'community', mode: 'direct' },
{ name: 'feedback', mode: 'direct' },
@ -188,7 +189,7 @@ describe('matchAction Logic', () => {
describe('Mode-based Filtering', () => {
it('should filter direct mode commands from matching', () => {
;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([
;(slashCommandRegistry.getAllCommands as Mock).mockReturnValue([
{ name: 'test', mode: 'direct' },
])
@ -197,7 +198,7 @@ describe('matchAction Logic', () => {
})
it('should allow submenu mode commands to match', () => {
;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([
;(slashCommandRegistry.getAllCommands as Mock).mockReturnValue([
{ name: 'test', mode: 'submenu' },
])
@ -206,7 +207,7 @@ describe('matchAction Logic', () => {
})
it('should treat undefined mode as submenu', () => {
;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([
;(slashCommandRegistry.getAllCommands as Mock).mockReturnValue([
{ name: 'test' }, // No mode specified
])
@ -227,7 +228,7 @@ describe('matchAction Logic', () => {
})
it('should handle empty command list', () => {
;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([])
;(slashCommandRegistry.getAllCommands as Mock).mockReturnValue([])
const result = matchAction('/anything', mockActions)
expect(result).toBeUndefined()
})

View File

@ -1,13 +1,13 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import * as React from 'react'
// Type alias for search mode
type SearchMode = 'scopes' | 'commands' | null
// Mock component to test tag display logic
const TagDisplay: React.FC<{ searchMode: SearchMode }> = ({ searchMode }) => {
if (!searchMode) return null
if (!searchMode)
return null
return (
<div className="flex items-center gap-1 text-xs text-text-tertiary">
@ -38,8 +38,10 @@ describe('Scope and Command Tags', () => {
describe('Search Mode Detection', () => {
const getSearchMode = (query: string): SearchMode => {
if (query.startsWith('@')) return 'scopes'
if (query.startsWith('/')) return 'commands'
if (query.startsWith('@'))
return 'scopes'
if (query.startsWith('/'))
return 'commands'
return null
}
@ -91,8 +93,10 @@ describe('Scope and Command Tags', () => {
const SearchComponent: React.FC<{ query: string }> = ({ query }) => {
let searchMode: SearchMode = null
if (query.startsWith('@')) searchMode = 'scopes'
else if (query.startsWith('/')) searchMode = 'commands'
if (query.startsWith('@'))
searchMode = 'scopes'
else if (query.startsWith('/'))
searchMode = 'commands'
return (
<div>

View File

@ -1,3 +1,4 @@
import type { MockedFunction } from 'vitest'
/**
* Test GotoAnything search error handling mechanisms
*
@ -9,38 +10,38 @@
*/
import { Actions, searchAnything } from '@/app/components/goto-anything/actions'
import { postMarketplace } from '@/service/base'
import { fetchAppList } from '@/service/apps'
import { postMarketplace } from '@/service/base'
import { fetchDatasets } from '@/service/datasets'
// Mock API functions
jest.mock('@/service/base', () => ({
postMarketplace: jest.fn(),
vi.mock('@/service/base', () => ({
postMarketplace: vi.fn(),
}))
jest.mock('@/service/apps', () => ({
fetchAppList: jest.fn(),
vi.mock('@/service/apps', () => ({
fetchAppList: vi.fn(),
}))
jest.mock('@/service/datasets', () => ({
fetchDatasets: jest.fn(),
vi.mock('@/service/datasets', () => ({
fetchDatasets: vi.fn(),
}))
const mockPostMarketplace = postMarketplace as jest.MockedFunction<typeof postMarketplace>
const mockFetchAppList = fetchAppList as jest.MockedFunction<typeof fetchAppList>
const mockFetchDatasets = fetchDatasets as jest.MockedFunction<typeof fetchDatasets>
const mockPostMarketplace = postMarketplace as MockedFunction<typeof postMarketplace>
const mockFetchAppList = fetchAppList as MockedFunction<typeof fetchAppList>
const mockFetchDatasets = fetchDatasets as MockedFunction<typeof fetchDatasets>
describe('GotoAnything Search Error Handling', () => {
beforeEach(() => {
jest.clearAllMocks()
vi.clearAllMocks()
// Suppress console.warn for clean test output
jest.spyOn(console, 'warn').mockImplementation(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {
// Suppress console.warn for clean test output
})
})
afterEach(() => {
jest.restoreAllMocks()
vi.restoreAllMocks()
})
describe('@plugin search error handling', () => {

View File

@ -1,17 +1,16 @@
import '@testing-library/jest-dom'
import { slashCommandRegistry } from '../../app/components/goto-anything/actions/commands/registry'
import type { SlashCommandHandler } from '../../app/components/goto-anything/actions/commands/types'
import { slashCommandRegistry } from '../../app/components/goto-anything/actions/commands/registry'
// Mock the registry
jest.mock('../../app/components/goto-anything/actions/commands/registry')
vi.mock('../../app/components/goto-anything/actions/commands/registry')
describe('Slash Command Dual-Mode System', () => {
const mockDirectCommand: SlashCommandHandler = {
name: 'docs',
description: 'Open documentation',
mode: 'direct',
execute: jest.fn(),
search: jest.fn().mockResolvedValue([
execute: vi.fn(),
search: vi.fn().mockResolvedValue([
{
id: 'docs',
title: 'Documentation',
@ -20,15 +19,15 @@ describe('Slash Command Dual-Mode System', () => {
data: { command: 'navigation.docs', args: {} },
},
]),
register: jest.fn(),
unregister: jest.fn(),
register: vi.fn(),
unregister: vi.fn(),
}
const mockSubmenuCommand: SlashCommandHandler = {
name: 'theme',
description: 'Change theme',
mode: 'submenu',
search: jest.fn().mockResolvedValue([
search: vi.fn().mockResolvedValue([
{
id: 'theme-light',
title: 'Light Theme',
@ -44,18 +43,20 @@ describe('Slash Command Dual-Mode System', () => {
data: { command: 'theme.set', args: { theme: 'dark' } },
},
]),
register: jest.fn(),
unregister: jest.fn(),
register: vi.fn(),
unregister: vi.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
;(slashCommandRegistry as any).findCommand = jest.fn((name: string) => {
if (name === 'docs') return mockDirectCommand
if (name === 'theme') return mockSubmenuCommand
vi.clearAllMocks()
;(slashCommandRegistry as any).findCommand = vi.fn((name: string) => {
if (name === 'docs')
return mockDirectCommand
if (name === 'theme')
return mockSubmenuCommand
return null
})
;(slashCommandRegistry as any).getAllCommands = jest.fn(() => [
;(slashCommandRegistry as any).getAllCommands = vi.fn(() => [
mockDirectCommand,
mockSubmenuCommand,
])
@ -63,8 +64,8 @@ describe('Slash Command Dual-Mode System', () => {
describe('Direct Mode Commands', () => {
it('should execute immediately when selected', () => {
const mockSetShow = jest.fn()
const mockSetSearchQuery = jest.fn()
const mockSetShow = vi.fn()
const mockSetSearchQuery = vi.fn()
// Simulate command selection
const handler = slashCommandRegistry.findCommand('docs')
@ -88,7 +89,7 @@ describe('Slash Command Dual-Mode System', () => {
})
it('should close modal after execution', () => {
const mockModalClose = jest.fn()
const mockModalClose = vi.fn()
const handler = slashCommandRegistry.findCommand('docs')
if (handler?.mode === 'direct' && handler.execute) {
@ -118,7 +119,7 @@ describe('Slash Command Dual-Mode System', () => {
})
it('should keep modal open for selection', () => {
const mockModalClose = jest.fn()
const mockModalClose = vi.fn()
const handler = slashCommandRegistry.findCommand('theme')
// For submenu mode, modal should not close immediately
@ -141,12 +142,12 @@ describe('Slash Command Dual-Mode System', () => {
const commandWithoutMode: SlashCommandHandler = {
name: 'test',
description: 'Test command',
search: jest.fn(),
register: jest.fn(),
unregister: jest.fn(),
search: vi.fn(),
register: vi.fn(),
unregister: vi.fn(),
}
;(slashCommandRegistry as any).findCommand = jest.fn(() => commandWithoutMode)
;(slashCommandRegistry as any).findCommand = vi.fn(() => commandWithoutMode)
const handler = slashCommandRegistry.findCommand('test')
// Default behavior should be submenu when mode is not specified
@ -189,7 +190,7 @@ describe('Slash Command Dual-Mode System', () => {
describe('Command Registration', () => {
it('should register both direct and submenu commands', () => {
mockDirectCommand.register?.({})
mockSubmenuCommand.register?.({ setTheme: jest.fn() })
mockSubmenuCommand.register?.({ setTheme: vi.fn() })
expect(mockDirectCommand.register).toHaveBeenCalled()
expect(mockSubmenuCommand.register).toHaveBeenCalled()

View File

@ -16,7 +16,7 @@ const getSupportedLocales = (): string[] => {
// Helper function to load translation file content
const loadTranslationContent = (locale: string): string => {
const filePath = path.join(I18N_DIR, locale, 'app-debug.ts')
const filePath = path.join(I18N_DIR, locale, 'app-debug.json')
if (!fs.existsSync(filePath))
throw new Error(`Translation file not found: ${filePath}`)
@ -24,14 +24,14 @@ const loadTranslationContent = (locale: string): string => {
return fs.readFileSync(filePath, 'utf-8')
}
// Helper function to check if upload features exist
// Helper function to check if upload features exist (supports flattened JSON)
const hasUploadFeatures = (content: string): { [key: string]: boolean } => {
return {
fileUpload: /fileUpload\s*:\s*{/.test(content),
imageUpload: /imageUpload\s*:\s*{/.test(content),
documentUpload: /documentUpload\s*:\s*{/.test(content),
audioUpload: /audioUpload\s*:\s*{/.test(content),
featureBar: /bar\s*:\s*{/.test(content),
fileUpload: /"feature\.fileUpload\.title"/.test(content),
imageUpload: /"feature\.imageUpload\.title"/.test(content),
documentUpload: /"feature\.documentUpload\.title"/.test(content),
audioUpload: /"feature\.audioUpload\.title"/.test(content),
featureBar: /"feature\.bar\.empty"/.test(content),
}
}
@ -43,14 +43,14 @@ describe('Upload Features i18n Translations - Issue #23062', () => {
console.log(`Testing ${supportedLocales.length} locales for upload features`)
})
test('all locales should have translation files', () => {
it('all locales should have translation files', () => {
supportedLocales.forEach((locale) => {
const filePath = path.join(I18N_DIR, locale, 'app-debug.ts')
const filePath = path.join(I18N_DIR, locale, 'app-debug.json')
expect(fs.existsSync(filePath)).toBe(true)
})
})
test('all locales should have required upload features', () => {
it('all locales should have required upload features', () => {
const results: { [locale: string]: { [feature: string]: boolean } } = {}
supportedLocales.forEach((locale) => {
@ -69,50 +69,47 @@ describe('Upload Features i18n Translations - Issue #23062', () => {
console.log('✅ All locales have complete upload features')
})
test('previously missing locales should now have audioUpload - Issue #23062', () => {
it('previously missing locales should now have audioUpload - Issue #23062', () => {
// These locales were specifically missing audioUpload
const previouslyMissingLocales = ['fa-IR', 'hi-IN', 'ro-RO', 'sl-SI', 'th-TH', 'uk-UA', 'vi-VN']
previouslyMissingLocales.forEach((locale) => {
const content = loadTranslationContent(locale)
// Verify audioUpload exists
expect(/audioUpload\s*:\s*{/.test(content)).toBe(true)
// Verify it has title and description
expect(/audioUpload[^}]*title\s*:/.test(content)).toBe(true)
expect(/audioUpload[^}]*description\s*:/.test(content)).toBe(true)
// Verify audioUpload exists with title and description (flattened JSON format)
expect(/"feature\.audioUpload\.title"/.test(content)).toBe(true)
expect(/"feature\.audioUpload\.description"/.test(content)).toBe(true)
console.log(`${locale} - Issue #23062 resolved: audioUpload feature present`)
})
})
test('upload features should have required properties', () => {
it('upload features should have required properties', () => {
supportedLocales.forEach((locale) => {
const content = loadTranslationContent(locale)
// Check fileUpload has required properties
if (/fileUpload\s*:\s*{/.test(content)) {
expect(/fileUpload[^}]*title\s*:/.test(content)).toBe(true)
expect(/fileUpload[^}]*description\s*:/.test(content)).toBe(true)
// Check fileUpload has required properties (flattened JSON format)
if (/"feature\.fileUpload\.title"/.test(content)) {
expect(/"feature\.fileUpload\.title"/.test(content)).toBe(true)
expect(/"feature\.fileUpload\.description"/.test(content)).toBe(true)
}
// Check imageUpload has required properties
if (/imageUpload\s*:\s*{/.test(content)) {
expect(/imageUpload[^}]*title\s*:/.test(content)).toBe(true)
expect(/imageUpload[^}]*description\s*:/.test(content)).toBe(true)
if (/"feature\.imageUpload\.title"/.test(content)) {
expect(/"feature\.imageUpload\.title"/.test(content)).toBe(true)
expect(/"feature\.imageUpload\.description"/.test(content)).toBe(true)
}
// Check documentUpload has required properties
if (/documentUpload\s*:\s*{/.test(content)) {
expect(/documentUpload[^}]*title\s*:/.test(content)).toBe(true)
expect(/documentUpload[^}]*description\s*:/.test(content)).toBe(true)
if (/"feature\.documentUpload\.title"/.test(content)) {
expect(/"feature\.documentUpload\.title"/.test(content)).toBe(true)
expect(/"feature\.documentUpload\.description"/.test(content)).toBe(true)
}
// Check audioUpload has required properties
if (/audioUpload\s*:\s*{/.test(content)) {
expect(/audioUpload[^}]*title\s*:/.test(content)).toBe(true)
expect(/audioUpload[^}]*description\s*:/.test(content)).toBe(true)
if (/"feature\.audioUpload\.title"/.test(content)) {
expect(/"feature\.audioUpload\.title"/.test(content)).toBe(true)
expect(/"feature\.audioUpload\.description"/.test(content)).toBe(true)
}
})
})

View File

@ -15,16 +15,16 @@ import {
} from '@/utils/navigation'
// Mock router for testing
const mockPush = jest.fn()
const mockPush = vi.fn()
const mockRouter = { push: mockPush }
describe('Navigation Utilities', () => {
beforeEach(() => {
jest.clearAllMocks()
vi.clearAllMocks()
})
describe('createNavigationPath', () => {
test('preserves query parameters by default', () => {
it('preserves query parameters by default', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=3&limit=10&keyword=test' },
writable: true,
@ -34,7 +34,7 @@ describe('Navigation Utilities', () => {
expect(path).toBe('/datasets/123/documents?page=3&limit=10&keyword=test')
})
test('returns clean path when preserveParams is false', () => {
it('returns clean path when preserveParams is false', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=3&limit=10' },
writable: true,
@ -44,7 +44,7 @@ describe('Navigation Utilities', () => {
expect(path).toBe('/datasets/123/documents')
})
test('handles empty query parameters', () => {
it('handles empty query parameters', () => {
Object.defineProperty(window, 'location', {
value: { search: '' },
writable: true,
@ -54,7 +54,7 @@ describe('Navigation Utilities', () => {
expect(path).toBe('/datasets/123/documents')
})
test('handles errors gracefully', () => {
it('handles errors gracefully', () => {
// Mock window.location to throw an error
Object.defineProperty(window, 'location', {
get: () => {
@ -63,7 +63,7 @@ describe('Navigation Utilities', () => {
configurable: true,
})
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { /* noop */ })
const path = createNavigationPath('/datasets/123/documents')
expect(path).toBe('/datasets/123/documents')
@ -74,7 +74,7 @@ describe('Navigation Utilities', () => {
})
describe('createBackNavigation', () => {
test('creates function that navigates with preserved params', () => {
it('creates function that navigates with preserved params', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=2&limit=25' },
writable: true,
@ -86,7 +86,7 @@ describe('Navigation Utilities', () => {
expect(mockPush).toHaveBeenCalledWith('/datasets/123/documents?page=2&limit=25')
})
test('creates function that navigates without params when specified', () => {
it('creates function that navigates without params when specified', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=2&limit=25' },
writable: true,
@ -100,7 +100,7 @@ describe('Navigation Utilities', () => {
})
describe('extractQueryParams', () => {
test('extracts specified parameters', () => {
it('extracts specified parameters', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=3&limit=10&keyword=test&other=value' },
writable: true,
@ -114,7 +114,7 @@ describe('Navigation Utilities', () => {
})
})
test('handles missing parameters', () => {
it('handles missing parameters', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=3' },
writable: true,
@ -126,7 +126,7 @@ describe('Navigation Utilities', () => {
})
})
test('handles errors gracefully', () => {
it('handles errors gracefully', () => {
Object.defineProperty(window, 'location', {
get: () => {
throw new Error('Location access denied')
@ -134,7 +134,7 @@ describe('Navigation Utilities', () => {
configurable: true,
})
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { /* noop */ })
const params = extractQueryParams(['page', 'limit'])
expect(params).toEqual({})
@ -145,7 +145,7 @@ describe('Navigation Utilities', () => {
})
describe('createNavigationPathWithParams', () => {
test('creates path with specified parameters', () => {
it('creates path with specified parameters', () => {
const path = createNavigationPathWithParams('/datasets/123/documents', {
page: 1,
limit: 25,
@ -155,7 +155,7 @@ describe('Navigation Utilities', () => {
expect(path).toBe('/datasets/123/documents?page=1&limit=25&keyword=search+term')
})
test('filters out empty values', () => {
it('filters out empty values', () => {
const path = createNavigationPathWithParams('/datasets/123/documents', {
page: 1,
limit: '',
@ -166,14 +166,14 @@ describe('Navigation Utilities', () => {
expect(path).toBe('/datasets/123/documents?page=1&keyword=test')
})
test('handles errors gracefully', () => {
it('handles errors gracefully', () => {
// Mock URLSearchParams to throw an error
const originalURLSearchParams = globalThis.URLSearchParams
globalThis.URLSearchParams = jest.fn(() => {
globalThis.URLSearchParams = vi.fn(() => {
throw new Error('URLSearchParams error')
}) as any
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { /* noop */ })
const path = createNavigationPathWithParams('/datasets/123/documents', { page: 1 })
expect(path).toBe('/datasets/123/documents')
@ -185,7 +185,7 @@ describe('Navigation Utilities', () => {
})
describe('mergeQueryParams', () => {
test('merges new params with existing ones', () => {
it('merges new params with existing ones', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=3&limit=10' },
writable: true,
@ -199,7 +199,7 @@ describe('Navigation Utilities', () => {
expect(result).toContain('keyword=test') // added
})
test('removes parameters when value is null', () => {
it('removes parameters when value is null', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=3&limit=10&keyword=test' },
writable: true,
@ -214,7 +214,7 @@ describe('Navigation Utilities', () => {
expect(result).toContain('filter=active')
})
test('creates fresh params when preserveExisting is false', () => {
it('creates fresh params when preserveExisting is false', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=3&limit=10' },
writable: true,
@ -228,7 +228,7 @@ describe('Navigation Utilities', () => {
})
describe('datasetNavigation', () => {
test('backToDocuments creates correct navigation function', () => {
it('backToDocuments creates correct navigation function', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=2&limit=25' },
writable: true,
@ -240,14 +240,14 @@ describe('Navigation Utilities', () => {
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents?page=2&limit=25')
})
test('toDocumentDetail creates correct navigation function', () => {
it('toDocumentDetail creates correct navigation function', () => {
const detailFn = datasetNavigation.toDocumentDetail(mockRouter, 'dataset-123', 'doc-456')
detailFn()
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents/doc-456')
})
test('toDocumentSettings creates correct navigation function', () => {
it('toDocumentSettings creates correct navigation function', () => {
const settingsFn = datasetNavigation.toDocumentSettings(mockRouter, 'dataset-123', 'doc-456')
settingsFn()
@ -256,7 +256,7 @@ describe('Navigation Utilities', () => {
})
describe('Real-world Integration Scenarios', () => {
test('complete user workflow: list -> detail -> back', () => {
it('complete user workflow: list -> detail -> back', () => {
// User starts on page 3 with search
Object.defineProperty(window, 'location', {
value: { search: '?page=3&keyword=API&limit=25' },
@ -273,7 +273,7 @@ describe('Navigation Utilities', () => {
expect(mockPush).toHaveBeenCalledWith('/datasets/main-dataset/documents?page=3&keyword=API&limit=25')
})
test('user applies filters then views document', () => {
it('user applies filters then views document', () => {
// Complex filter state
Object.defineProperty(window, 'location', {
value: { search: '?page=1&limit=50&status=active&type=pdf&sort=created_at&order=desc' },
@ -288,7 +288,7 @@ describe('Navigation Utilities', () => {
})
describe('Edge Cases and Error Handling', () => {
test('handles special characters in query parameters', () => {
it('handles special characters in query parameters', () => {
Object.defineProperty(window, 'location', {
value: { search: '?keyword=hello%20world&filter=type%3Apdf&tag=%E4%B8%AD%E6%96%87' },
writable: true,
@ -300,7 +300,7 @@ describe('Navigation Utilities', () => {
expect(path).toContain('%E4%B8%AD%E6%96%87')
})
test('handles duplicate query parameters', () => {
it('handles duplicate query parameters', () => {
Object.defineProperty(window, 'location', {
value: { search: '?tag=tag1&tag=tag2&tag=tag3' },
writable: true,
@ -311,7 +311,7 @@ describe('Navigation Utilities', () => {
expect(params.tag).toBe('tag1')
})
test('handles very long query strings', () => {
it('handles very long query strings', () => {
const longValue = 'a'.repeat(1000)
Object.defineProperty(window, 'location', {
value: { search: `?data=${longValue}` },
@ -323,7 +323,7 @@ describe('Navigation Utilities', () => {
expect(path.length).toBeGreaterThan(1000)
})
test('handles empty string values in query parameters', () => {
it('handles empty string values in query parameters', () => {
const path = createNavigationPathWithParams('/datasets/123/documents', {
page: 1,
keyword: '',
@ -336,7 +336,7 @@ describe('Navigation Utilities', () => {
expect(path).not.toContain('filter=')
})
test('handles null and undefined values in mergeQueryParams', () => {
it('handles null and undefined values in mergeQueryParams', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=1&limit=10&keyword=test' },
writable: true,
@ -355,7 +355,7 @@ describe('Navigation Utilities', () => {
expect(result).toContain('sort=name')
})
test('handles navigation with hash fragments', () => {
it('handles navigation with hash fragments', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=1', hash: '#section-2' },
writable: true,
@ -366,7 +366,7 @@ describe('Navigation Utilities', () => {
expect(path).toBe('/datasets/123/documents?page=1')
})
test('handles malformed query strings gracefully', () => {
it('handles malformed query strings gracefully', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=1&invalid&limit=10&=value&key=' },
writable: true,
@ -382,7 +382,7 @@ describe('Navigation Utilities', () => {
})
describe('Performance Tests', () => {
test('handles large number of query parameters efficiently', () => {
it('handles large number of query parameters efficiently', () => {
const manyParams = Array.from({ length: 50 }, (_, i) => `param${i}=value${i}`).join('&')
Object.defineProperty(window, 'location', {
value: { search: `?${manyParams}` },

View File

@ -10,8 +10,8 @@
import { render, screen, waitFor } from '@testing-library/react'
import { ThemeProvider } from 'next-themes'
import useTheme from '@/hooks/use-theme'
import { useEffect, useState } from 'react'
import useTheme from '@/hooks/use-theme'
const DARK_MODE_MEDIA_QUERY = /prefers-color-scheme:\s*dark/i
@ -76,14 +76,14 @@ const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = fa
return mediaQueryList
}
jest.spyOn(window, 'matchMedia').mockImplementation(mockMatchMedia)
vi.spyOn(window, 'matchMedia').mockImplementation(mockMatchMedia)
}
// Helper function to create timing page component
const createTimingPageComponent = (
timingData: Array<{ phase: string; timestamp: number; styles: { backgroundColor: string; color: string } }>,
timingData: Array<{ phase: string, timestamp: number, styles: { backgroundColor: string, color: string } }>,
) => {
const recordTiming = (phase: string, styles: { backgroundColor: string; color: string }) => {
const recordTiming = (phase: string, styles: { backgroundColor: string, color: string }) => {
timingData.push({
phase,
timestamp: performance.now(),
@ -113,7 +113,17 @@ const createTimingPageComponent = (
style={currentStyles}
>
<div data-testid="timing-status">
Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual: {isDark ? 'dark' : 'light'}
Phase:
{' '}
{mounted ? 'CSR' : 'Initial'}
{' '}
| Theme:
{' '}
{theme}
{' '}
| Visual:
{' '}
{isDark ? 'dark' : 'light'}
</div>
</div>
)
@ -124,7 +134,7 @@ const createTimingPageComponent = (
// Helper function to create CSS test component
const createCSSTestComponent = (
cssStates: Array<{ className: string; timestamp: number }>,
cssStates: Array<{ className: string, timestamp: number }>,
) => {
const recordCSSState = (className: string) => {
cssStates.push({
@ -151,7 +161,10 @@ const createCSSTestComponent = (
data-testid="css-component"
className={className}
>
<div data-testid="css-classes">Classes: {className}</div>
<div data-testid="css-classes">
Classes:
{className}
</div>
</div>
)
}
@ -161,7 +174,7 @@ const createCSSTestComponent = (
// Helper function to create performance test component
const createPerformanceTestComponent = (
performanceMarks: Array<{ event: string; timestamp: number }>,
performanceMarks: Array<{ event: string, timestamp: number }>,
) => {
const recordPerformanceMark = (event: string) => {
performanceMarks.push({ event, timestamp: performance.now() })
@ -186,7 +199,13 @@ const createPerformanceTestComponent = (
return (
<div data-testid="performance-test">
Mounted: {mounted.toString()} | Theme: {theme || 'loading'}
Mounted:
{' '}
{mounted.toString()}
{' '}
| Theme:
{' '}
{theme || 'loading'}
</div>
)
}
@ -216,10 +235,14 @@ const PageComponent = () => {
Dify Application
</h1>
<div data-testid="theme-indicator">
Current Theme: {mounted ? theme : 'unknown'}
Current Theme:
{' '}
{mounted ? theme : 'unknown'}
</div>
<div data-testid="visual-appearance">
Appearance: {isDark ? 'dark' : 'light'}
Appearance:
{' '}
{isDark ? 'dark' : 'light'}
</div>
</div>
</div>
@ -240,8 +263,8 @@ const TestThemeProvider = ({ children }: { children: React.ReactNode }) => (
describe('Real Browser Environment Dark Mode Flicker Test', () => {
beforeEach(() => {
jest.restoreAllMocks()
jest.clearAllMocks()
vi.restoreAllMocks()
vi.clearAllMocks()
if (typeof window !== 'undefined') {
try {
window.localStorage.clear()
@ -254,7 +277,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
})
describe('Page Refresh Scenario Simulation', () => {
test('simulates complete page loading process with dark theme', async () => {
it('simulates complete page loading process with dark theme', async () => {
// Setup: User previously selected dark mode
setupMockEnvironment('dark')
@ -286,7 +309,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
console.log('State change detection: Initial -> Final')
})
test('handles light theme correctly', async () => {
it('handles light theme correctly', async () => {
setupMockEnvironment('light')
render(
@ -302,7 +325,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: light')
})
test('handles system theme with dark preference', async () => {
it('handles system theme with dark preference', async () => {
setupMockEnvironment('system', true) // system theme, dark preference
render(
@ -318,7 +341,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: dark')
})
test('handles system theme with light preference', async () => {
it('handles system theme with light preference', async () => {
setupMockEnvironment('system', false) // system theme, light preference
render(
@ -334,7 +357,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: light')
})
test('handles no stored theme (defaults to system)', async () => {
it('handles no stored theme (defaults to system)', async () => {
setupMockEnvironment(null, false) // no stored theme, system prefers light
render(
@ -348,10 +371,10 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
})
})
test('measures timing window of style changes', async () => {
it('measures timing window of style changes', async () => {
setupMockEnvironment('dark')
const timingData: Array<{ phase: string; timestamp: number; styles: any }> = []
const timingData: Array<{ phase: string, timestamp: number, styles: any }> = []
const TimingPageComponent = createTimingPageComponent(timingData)
render(
@ -384,10 +407,10 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
})
describe('CSS Application Timing Tests', () => {
test('checks CSS class changes causing flicker', async () => {
it('checks CSS class changes causing flicker', async () => {
setupMockEnvironment('dark')
const cssStates: Array<{ className: string; timestamp: number }> = []
const cssStates: Array<{ className: string, timestamp: number }> = []
const CSSTestComponent = createCSSTestComponent(cssStates)
render(
@ -420,16 +443,16 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
})
describe('Edge Cases and Error Handling', () => {
test('handles localStorage access errors gracefully', async () => {
it('handles localStorage access errors gracefully', async () => {
setupMockEnvironment(null)
const mockStorage = {
getItem: jest.fn(() => {
getItem: vi.fn(() => {
throw new Error('LocalStorage access denied')
}),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
}
Object.defineProperty(window, 'localStorage', {
@ -457,7 +480,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
}
})
test('handles invalid theme values in localStorage', async () => {
it('handles invalid theme values in localStorage', async () => {
setupMockEnvironment('invalid-theme-value')
render(
@ -477,8 +500,8 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
})
describe('Performance and Regression Tests', () => {
test('verifies ThemeProvider position fix reduces initialization delay', async () => {
const performanceMarks: Array<{ event: string; timestamp: number }> = []
it('verifies ThemeProvider position fix reduces initialization delay', async () => {
const performanceMarks: Array<{ event: string, timestamp: number }> = []
setupMockEnvironment('dark')
@ -507,7 +530,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
})
describe('Solution Requirements Definition', () => {
test('defines technical requirements to eliminate flicker', () => {
it('defines technical requirements to eliminate flicker', () => {
const technicalRequirements = {
ssrConsistency: 'SSR and CSR must render identical initial styles',
synchronousDetection: 'Theme detection must complete synchronously before first render',

View File

@ -14,8 +14,8 @@ describe('Unified Tags Editing - Pure Logic Tests', () => {
// This is the valueNotChanged logic from TagSelector component
const valueNotChanged
= currentValue.length === newSelectedTagIDs.length
&& currentValue.every(v => newSelectedTagIDs.includes(v))
&& newSelectedTagIDs.every(v => currentValue.includes(v))
&& currentValue.every(v => newSelectedTagIDs.includes(v))
&& newSelectedTagIDs.every(v => currentValue.includes(v))
expect(valueNotChanged).toBe(false)
})
@ -26,8 +26,8 @@ describe('Unified Tags Editing - Pure Logic Tests', () => {
const valueNotChanged
= currentValue.length === newSelectedTagIDs.length
&& currentValue.every(v => newSelectedTagIDs.includes(v))
&& newSelectedTagIDs.every(v => currentValue.includes(v))
&& currentValue.every(v => newSelectedTagIDs.includes(v))
&& newSelectedTagIDs.every(v => currentValue.includes(v))
expect(valueNotChanged).toBe(true)
})
@ -70,7 +70,7 @@ describe('Unified Tags Editing - Pure Logic Tests', () => {
})
describe('Fallback Logic (from layout-main.tsx)', () => {
type Tag = { id: string; name: string }
type Tag = { id: string, name: string }
type AppDetail = { tags: Tag[] }
type FallbackResult = { tags?: Tag[] } | null
// no-op
@ -316,7 +316,7 @@ describe('Unified Tags Editing - Pure Logic Tests', () => {
]
// Filter out invalid entries
const validTags = mixedData.filter((tag): tag is { id: string; name: string; type: string; binding_count: number } =>
const validTags = mixedData.filter((tag): tag is { id: string, name: string, type: string, binding_count: number } =>
tag != null
&& typeof tag === 'object'
&& 'id' in tag

View File

@ -1,15 +1,16 @@
import { BlockEnum } from '@/app/components/workflow/types'
import type { Mock } from 'vitest'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
// Type for mocked store
type MockWorkflowStore = {
showOnboarding: boolean
setShowOnboarding: jest.Mock
setShowOnboarding: Mock
hasShownOnboarding: boolean
setHasShownOnboarding: jest.Mock
setHasShownOnboarding: Mock
hasSelectedStartNode: boolean
setHasSelectedStartNode: jest.Mock
setShouldAutoOpenStartNodeSelector: jest.Mock
setHasSelectedStartNode: Mock
setShouldAutoOpenStartNodeSelector: Mock
notInitialWorkflow: boolean
}
@ -20,11 +21,11 @@ type MockNode = {
}
// Mock zustand store
jest.mock('@/app/components/workflow/store')
vi.mock('@/app/components/workflow/store')
// Mock ReactFlow store
const mockGetNodes = jest.fn()
jest.mock('reactflow', () => ({
const mockGetNodes = vi.fn()
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
@ -33,16 +34,16 @@ jest.mock('reactflow', () => ({
}))
describe('Workflow Onboarding Integration Logic', () => {
const mockSetShowOnboarding = jest.fn()
const mockSetHasSelectedStartNode = jest.fn()
const mockSetHasShownOnboarding = jest.fn()
const mockSetShouldAutoOpenStartNodeSelector = jest.fn()
const mockSetShowOnboarding = vi.fn()
const mockSetHasSelectedStartNode = vi.fn()
const mockSetHasShownOnboarding = vi.fn()
const mockSetShouldAutoOpenStartNodeSelector = vi.fn()
beforeEach(() => {
jest.clearAllMocks()
vi.clearAllMocks()
// Mock store implementation
;(useWorkflowStore as jest.Mock).mockReturnValue({
;(useWorkflowStore as Mock).mockReturnValue({
showOnboarding: false,
setShowOnboarding: mockSetShowOnboarding,
hasSelectedStartNode: false,
@ -102,9 +103,9 @@ describe('Workflow Onboarding Integration Logic', () => {
// Simulate the validation logic from use-nodes-sync-draft.ts
const isValidStartNode = mockNode.data.type === BlockEnum.Start
|| mockNode.data.type === BlockEnum.TriggerSchedule
|| mockNode.data.type === BlockEnum.TriggerWebhook
|| mockNode.data.type === BlockEnum.TriggerPlugin
|| mockNode.data.type === BlockEnum.TriggerSchedule
|| mockNode.data.type === BlockEnum.TriggerWebhook
|| mockNode.data.type === BlockEnum.TriggerPlugin
expect(isValidStartNode).toBe(true)
})
@ -116,9 +117,9 @@ describe('Workflow Onboarding Integration Logic', () => {
}
const isValidStartNode = mockNode.data.type === BlockEnum.Start
|| mockNode.data.type === BlockEnum.TriggerSchedule
|| mockNode.data.type === BlockEnum.TriggerWebhook
|| mockNode.data.type === BlockEnum.TriggerPlugin
|| mockNode.data.type === BlockEnum.TriggerSchedule
|| mockNode.data.type === BlockEnum.TriggerWebhook
|| mockNode.data.type === BlockEnum.TriggerPlugin
expect(isValidStartNode).toBe(true)
})
@ -130,9 +131,9 @@ describe('Workflow Onboarding Integration Logic', () => {
}
const isValidStartNode = mockNode.data.type === BlockEnum.Start
|| mockNode.data.type === BlockEnum.TriggerSchedule
|| mockNode.data.type === BlockEnum.TriggerWebhook
|| mockNode.data.type === BlockEnum.TriggerPlugin
|| mockNode.data.type === BlockEnum.TriggerSchedule
|| mockNode.data.type === BlockEnum.TriggerWebhook
|| mockNode.data.type === BlockEnum.TriggerPlugin
expect(isValidStartNode).toBe(true)
})
@ -144,9 +145,9 @@ describe('Workflow Onboarding Integration Logic', () => {
}
const isValidStartNode = mockNode.data.type === BlockEnum.Start
|| mockNode.data.type === BlockEnum.TriggerSchedule
|| mockNode.data.type === BlockEnum.TriggerWebhook
|| mockNode.data.type === BlockEnum.TriggerPlugin
|| mockNode.data.type === BlockEnum.TriggerSchedule
|| mockNode.data.type === BlockEnum.TriggerWebhook
|| mockNode.data.type === BlockEnum.TriggerPlugin
expect(isValidStartNode).toBe(true)
})
@ -158,9 +159,9 @@ describe('Workflow Onboarding Integration Logic', () => {
}
const isValidStartNode = mockNode.data.type === BlockEnum.Start
|| mockNode.data.type === BlockEnum.TriggerSchedule
|| mockNode.data.type === BlockEnum.TriggerWebhook
|| mockNode.data.type === BlockEnum.TriggerPlugin
|| mockNode.data.type === BlockEnum.TriggerSchedule
|| mockNode.data.type === BlockEnum.TriggerWebhook
|| mockNode.data.type === BlockEnum.TriggerPlugin
expect(isValidStartNode).toBe(false)
})
@ -373,12 +374,12 @@ describe('Workflow Onboarding Integration Logic', () => {
it('should trigger onboarding for new workflow when draft does not exist', () => {
// Simulate the error handling logic from use-workflow-init.ts
const error = {
json: jest.fn().mockResolvedValue({ code: 'draft_workflow_not_exist' }),
json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_exist' }),
bodyUsed: false,
}
const mockWorkflowStore = {
setState: jest.fn(),
setState: vi.fn(),
}
// Simulate error handling
@ -404,7 +405,7 @@ describe('Workflow Onboarding Integration Logic', () => {
it('should not trigger onboarding for existing workflows', () => {
// Simulate successful draft fetch
const mockWorkflowStore = {
setState: jest.fn(),
setState: vi.fn(),
}
// Normal initialization path should not set showOnboarding: true
@ -419,7 +420,7 @@ describe('Workflow Onboarding Integration Logic', () => {
})
it('should create empty draft with proper structure', () => {
const mockSyncWorkflowDraft = jest.fn()
const mockSyncWorkflowDraft = vi.fn()
const appId = 'test-app-id'
// Simulate the syncWorkflowDraft call from use-workflow-init.ts
@ -467,7 +468,7 @@ describe('Workflow Onboarding Integration Logic', () => {
mockGetNodes.mockReturnValue([])
// Mock store with proper state for auto-detection
;(useWorkflowStore as jest.Mock).mockReturnValue({
;(useWorkflowStore as Mock).mockReturnValue({
showOnboarding: false,
hasShownOnboarding: false,
notInitialWorkflow: false,
@ -550,7 +551,7 @@ describe('Workflow Onboarding Integration Logic', () => {
mockGetNodes.mockReturnValue([])
// Mock store with hasShownOnboarding = true
;(useWorkflowStore as jest.Mock).mockReturnValue({
;(useWorkflowStore as Mock).mockReturnValue({
showOnboarding: false,
hasShownOnboarding: true, // Already shown in this session
notInitialWorkflow: false,
@ -584,7 +585,7 @@ describe('Workflow Onboarding Integration Logic', () => {
mockGetNodes.mockReturnValue([])
// Mock store with notInitialWorkflow = true (initial creation)
;(useWorkflowStore as jest.Mock).mockReturnValue({
;(useWorkflowStore as Mock).mockReturnValue({
showOnboarding: false,
hasShownOnboarding: false,
notInitialWorkflow: true, // Initial workflow creation

View File

@ -6,7 +6,7 @@
*/
import { render, screen } from '@testing-library/react'
import React from 'react'
import * as React from 'react'
// Mock environment variables before importing constants
const originalEnv = process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
@ -19,7 +19,7 @@ function setupEnvironment(value?: string) {
delete process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
// Clear module cache to force re-evaluation
jest.resetModules()
vi.resetModules()
}
function restoreEnvironment() {
@ -28,37 +28,42 @@ function restoreEnvironment() {
else
delete process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
jest.resetModules()
vi.resetModules()
}
// Mock i18next with proper implementation
jest.mock('react-i18next', () => ({
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
if (key.includes('MaxParallelismTitle')) return 'Max Parallelism'
if (key.includes('MaxParallelismDesc')) return 'Maximum number of parallel executions'
if (key.includes('parallelMode')) return 'Parallel Mode'
if (key.includes('parallelPanelDesc')) return 'Enable parallel execution'
if (key.includes('errorResponseMethod')) return 'Error Response Method'
if (key.includes('MaxParallelismTitle'))
return 'Max Parallelism'
if (key.includes('MaxParallelismDesc'))
return 'Maximum number of parallel executions'
if (key.includes('parallelMode'))
return 'Parallel Mode'
if (key.includes('parallelPanelDesc'))
return 'Enable parallel execution'
if (key.includes('errorResponseMethod'))
return 'Error Response Method'
return key
},
}),
initReactI18next: {
type: '3rdParty',
init: jest.fn(),
init: vi.fn(),
},
}))
// Mock i18next module completely to prevent initialization issues
jest.mock('i18next', () => ({
use: jest.fn().mockReturnThis(),
init: jest.fn().mockReturnThis(),
t: jest.fn(key => key),
vi.mock('i18next', () => ({
use: vi.fn().mockReturnThis(),
init: vi.fn().mockReturnThis(),
t: vi.fn(key => key),
isInitialized: true,
}))
// Mock the useConfig hook
jest.mock('@/app/components/workflow/nodes/iteration/use-config', () => ({
vi.mock('@/app/components/workflow/nodes/iteration/use-config', () => ({
__esModule: true,
default: () => ({
inputs: {
@ -66,82 +71,39 @@ jest.mock('@/app/components/workflow/nodes/iteration/use-config', () => ({
parallel_nums: 5,
error_handle_mode: 'terminated',
},
changeParallel: jest.fn(),
changeParallelNums: jest.fn(),
changeErrorHandleMode: jest.fn(),
changeParallel: vi.fn(),
changeParallelNums: vi.fn(),
changeErrorHandleMode: vi.fn(),
}),
}))
// Mock other components
jest.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => {
return function MockVarReferencePicker() {
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: function MockVarReferencePicker() {
return <div data-testid="var-reference-picker">VarReferencePicker</div>
}
})
},
}))
jest.mock('@/app/components/workflow/nodes/_base/components/split', () => {
return function MockSplit() {
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: function MockSplit() {
return <div data-testid="split">Split</div>
}
})
},
}))
jest.mock('@/app/components/workflow/nodes/_base/components/field', () => {
return function MockField({ title, children }: { title: string, children: React.ReactNode }) {
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: function MockField({ title, children }: { title: string, children: React.ReactNode }) {
return (
<div data-testid="field">
<label>{title}</label>
{children}
</div>
)
}
})
},
}))
jest.mock('@/app/components/base/switch', () => {
return function MockSwitch({ defaultValue }: { defaultValue: boolean }) {
return <input type="checkbox" defaultChecked={defaultValue} data-testid="switch" />
}
})
jest.mock('@/app/components/base/select', () => {
return function MockSelect() {
return <select data-testid="select">Select</select>
}
})
// Use defaultValue to avoid controlled input warnings
jest.mock('@/app/components/base/slider', () => {
return function MockSlider({ value, max, min }: { value: number, max: number, min: number }) {
return (
<input
type="range"
defaultValue={value}
max={max}
min={min}
data-testid="slider"
data-max={max}
data-min={min}
readOnly
/>
)
}
})
// Use defaultValue to avoid controlled input warnings
jest.mock('@/app/components/base/input', () => {
return function MockInput({ type, max, min, value }: { type: string, max: number, min: number, value: number }) {
return (
<input
type={type}
defaultValue={value}
max={max}
min={min}
data-testid="number-input"
data-max={max}
data-min={min}
readOnly
/>
)
}
const getParallelControls = () => ({
numberInput: screen.getByRole('spinbutton'),
slider: screen.getByRole('slider'),
})
describe('MAX_PARALLEL_LIMIT Configuration Bug', () => {
@ -160,7 +122,7 @@ describe('MAX_PARALLEL_LIMIT Configuration Bug', () => {
}
beforeEach(() => {
jest.clearAllMocks()
vi.clearAllMocks()
})
afterEach(() => {
@ -172,115 +134,114 @@ describe('MAX_PARALLEL_LIMIT Configuration Bug', () => {
})
describe('Environment Variable Parsing', () => {
it('should parse MAX_PARALLEL_LIMIT from NEXT_PUBLIC_MAX_PARALLEL_LIMIT environment variable', () => {
it('should parse MAX_PARALLEL_LIMIT from NEXT_PUBLIC_MAX_PARALLEL_LIMIT environment variable', async () => {
setupEnvironment('25')
const { MAX_PARALLEL_LIMIT } = require('@/config')
const { MAX_PARALLEL_LIMIT } = await import('@/config')
expect(MAX_PARALLEL_LIMIT).toBe(25)
})
it('should fallback to default when environment variable is not set', () => {
it('should fallback to default when environment variable is not set', async () => {
setupEnvironment() // No environment variable
const { MAX_PARALLEL_LIMIT } = require('@/config')
const { MAX_PARALLEL_LIMIT } = await import('@/config')
expect(MAX_PARALLEL_LIMIT).toBe(10)
})
it('should handle invalid environment variable values', () => {
it('should handle invalid environment variable values', async () => {
setupEnvironment('invalid')
const { MAX_PARALLEL_LIMIT } = require('@/config')
const { MAX_PARALLEL_LIMIT } = await import('@/config')
// Should fall back to default when parsing fails
expect(MAX_PARALLEL_LIMIT).toBe(10)
})
it('should handle empty environment variable', () => {
it('should handle empty environment variable', async () => {
setupEnvironment('')
const { MAX_PARALLEL_LIMIT } = require('@/config')
const { MAX_PARALLEL_LIMIT } = await import('@/config')
// Should fall back to default when empty
expect(MAX_PARALLEL_LIMIT).toBe(10)
})
// Edge cases for boundary values
it('should clamp MAX_PARALLEL_LIMIT to MIN when env is 0 or negative', () => {
it('should clamp MAX_PARALLEL_LIMIT to MIN when env is 0 or negative', async () => {
setupEnvironment('0')
let { MAX_PARALLEL_LIMIT } = require('@/config')
let { MAX_PARALLEL_LIMIT } = await import('@/config')
expect(MAX_PARALLEL_LIMIT).toBe(10) // Falls back to default
setupEnvironment('-5')
;({ MAX_PARALLEL_LIMIT } = require('@/config'))
;({ MAX_PARALLEL_LIMIT } = await import('@/config'))
expect(MAX_PARALLEL_LIMIT).toBe(10) // Falls back to default
})
it('should handle float numbers by parseInt behavior', () => {
it('should handle float numbers by parseInt behavior', async () => {
setupEnvironment('12.7')
const { MAX_PARALLEL_LIMIT } = require('@/config')
const { MAX_PARALLEL_LIMIT } = await import('@/config')
// parseInt truncates to integer
expect(MAX_PARALLEL_LIMIT).toBe(12)
})
})
describe('UI Component Integration (Main Fix Verification)', () => {
it('should render iteration panel with environment-configured max value', () => {
it('should render iteration panel with environment-configured max value', async () => {
// Set environment variable to a different value
setupEnvironment('30')
// Import Panel after setting environment
const Panel = require('@/app/components/workflow/nodes/iteration/panel').default
const { MAX_PARALLEL_LIMIT } = require('@/config')
const Panel = await import('@/app/components/workflow/nodes/iteration/panel').then(mod => mod.default)
const { MAX_PARALLEL_LIMIT } = await import('@/config')
render(
<Panel
id="test-node"
// @ts-expect-error key type mismatch
data={mockNodeData.data}
/>,
)
// Behavior-focused assertion: UI max should equal MAX_PARALLEL_LIMIT
const numberInput = screen.getByTestId('number-input')
expect(numberInput).toHaveAttribute('data-max', String(MAX_PARALLEL_LIMIT))
const slider = screen.getByTestId('slider')
expect(slider).toHaveAttribute('data-max', String(MAX_PARALLEL_LIMIT))
const { numberInput, slider } = getParallelControls()
expect(numberInput).toHaveAttribute('max', String(MAX_PARALLEL_LIMIT))
expect(slider).toHaveAttribute('aria-valuemax', String(MAX_PARALLEL_LIMIT))
// Verify the actual values
expect(MAX_PARALLEL_LIMIT).toBe(30)
expect(numberInput.getAttribute('data-max')).toBe('30')
expect(slider.getAttribute('data-max')).toBe('30')
expect(numberInput.getAttribute('max')).toBe('30')
expect(slider.getAttribute('aria-valuemax')).toBe('30')
})
it('should maintain UI consistency with different environment values', () => {
it('should maintain UI consistency with different environment values', async () => {
setupEnvironment('15')
const Panel = require('@/app/components/workflow/nodes/iteration/panel').default
const { MAX_PARALLEL_LIMIT } = require('@/config')
const Panel = await import('@/app/components/workflow/nodes/iteration/panel').then(mod => mod.default)
const { MAX_PARALLEL_LIMIT } = await import('@/config')
render(
<Panel
id="test-node"
// @ts-expect-error key type mismatch
data={mockNodeData.data}
/>,
)
// Both input and slider should use the same max value from MAX_PARALLEL_LIMIT
const numberInput = screen.getByTestId('number-input')
const slider = screen.getByTestId('slider')
const { numberInput, slider } = getParallelControls()
expect(numberInput.getAttribute('data-max')).toBe(slider.getAttribute('data-max'))
expect(numberInput.getAttribute('data-max')).toBe(String(MAX_PARALLEL_LIMIT))
expect(numberInput.getAttribute('max')).toBe(slider.getAttribute('aria-valuemax'))
expect(numberInput.getAttribute('max')).toBe(String(MAX_PARALLEL_LIMIT))
})
})
describe('Legacy Constant Verification (For Transition Period)', () => {
// Marked as transition/deprecation tests
it('should maintain MAX_ITERATION_PARALLEL_NUM for backward compatibility', () => {
const { MAX_ITERATION_PARALLEL_NUM } = require('@/app/components/workflow/constants')
it('should maintain MAX_ITERATION_PARALLEL_NUM for backward compatibility', async () => {
const { MAX_ITERATION_PARALLEL_NUM } = await import('@/app/components/workflow/constants')
expect(typeof MAX_ITERATION_PARALLEL_NUM).toBe('number')
expect(MAX_ITERATION_PARALLEL_NUM).toBe(10) // Hardcoded legacy value
})
it('should demonstrate MAX_PARALLEL_LIMIT vs legacy constant difference', () => {
it('should demonstrate MAX_PARALLEL_LIMIT vs legacy constant difference', async () => {
setupEnvironment('50')
const { MAX_PARALLEL_LIMIT } = require('@/config')
const { MAX_ITERATION_PARALLEL_NUM } = require('@/app/components/workflow/constants')
const { MAX_PARALLEL_LIMIT } = await import('@/config')
const { MAX_ITERATION_PARALLEL_NUM } = await import('@/app/components/workflow/constants')
// MAX_PARALLEL_LIMIT is configurable, MAX_ITERATION_PARALLEL_NUM is not
expect(MAX_PARALLEL_LIMIT).toBe(50)
@ -290,9 +251,9 @@ describe('MAX_PARALLEL_LIMIT Configuration Bug', () => {
})
describe('Constants Validation', () => {
it('should validate that required constants exist and have correct types', () => {
const { MAX_PARALLEL_LIMIT } = require('@/config')
const { MIN_ITERATION_PARALLEL_NUM } = require('@/app/components/workflow/constants')
it('should validate that required constants exist and have correct types', async () => {
const { MAX_PARALLEL_LIMIT } = await import('@/config')
const { MIN_ITERATION_PARALLEL_NUM } = await import('@/app/components/workflow/constants')
expect(typeof MAX_PARALLEL_LIMIT).toBe('number')
expect(typeof MIN_ITERATION_PARALLEL_NUM).toBe('number')
expect(MAX_PARALLEL_LIMIT).toBeGreaterThanOrEqual(MIN_ITERATION_PARALLEL_NUM)

View File

@ -5,15 +5,16 @@
* components have been properly fixed by replacing dangerouslySetInnerHTML with safe React rendering.
*/
import React from 'react'
import { cleanup, render } from '@testing-library/react'
import '@testing-library/jest-dom'
import * as React from 'react'
import BlockInput from '../app/components/base/block-input'
import SupportVarInput from '../app/components/workflow/nodes/_base/components/support-var-input'
// Mock styles
jest.mock('../app/components/app/configuration/base/var-highlight/style.module.css', () => ({
item: 'mock-item-class',
vi.mock('../app/components/app/configuration/base/var-highlight/style.module.css', () => ({
default: {
item: 'mock-item-class',
},
}))
describe('XSS Prevention - Block Input and Support Var Input Security', () => {

View File

@ -1,4 +1,4 @@
import React from 'react'
import * as React from 'react'
import Main from '@/app/components/app/log-annotation'
import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type'

View File

@ -1,4 +1,4 @@
import React from 'react'
import * as React from 'react'
import Configuration from '@/app/components/app/configuration'
const IConfiguration = async () => {

View File

@ -1,9 +1,9 @@
import React from 'react'
import type { Locale } from '@/i18n-config'
import * as React from 'react'
import DevelopMain from '@/app/components/develop'
export type IDevelopProps = {
params: Promise<{ locale: Locale; appId: string }>
params: Promise<{ locale: Locale, appId: string }>
}
const Develop = async (props: IDevelopProps) => {

View File

@ -1,8 +1,7 @@
'use client'
import type { FC } from 'react'
import { useUnmount } from 'ahooks'
import React, { useCallback, useEffect, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import type { NavIcon } from '@/app/components/app-sidebar/navLink'
import type { App } from '@/types/app'
import {
RiDashboard2Fill,
RiDashboard2Line,
@ -13,21 +12,24 @@ import {
RiTerminalWindowFill,
RiTerminalWindowLine,
} from '@remixicon/react'
import { useUnmount } from 'ahooks'
import dynamic from 'next/dynamic'
import { usePathname, useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import s from './style.module.css'
import cn from '@/utils/classnames'
import { useStore } from '@/app/components/app/store'
import AppSideBar from '@/app/components/app-sidebar'
import type { NavIcon } from '@/app/components/app-sidebar/navLink'
import { fetchAppDetailDirect } from '@/service/apps'
import { useAppContext } from '@/context/app-context'
import { useStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { type App, AppModeEnum } from '@/types/app'
import useDocumentTitle from '@/hooks/use-document-title'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import dynamic from 'next/dynamic'
import { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { fetchAppDetailDirect } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import s from './style.module.css'
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
ssr: false,
@ -68,32 +70,32 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const navConfig = [
...(isCurrentWorkspaceEditor
? [{
name: t('common.appMenus.promptEng'),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
}]
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
}]
: []
),
{
name: t('common.appMenus.apiAccess'),
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
},
...(isCurrentWorkspaceEditor
? [{
name: mode !== AppModeEnum.WORKFLOW
? t('common.appMenus.logAndAnn')
: t('common.appMenus.logs'),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
}]
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
}]
: []
),
{
name: t('common.appMenus.overview'),
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
@ -102,7 +104,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
return navConfig
}, [t])
useDocumentTitle(appDetail?.name || t('common.menus.appDetail'))
useDocumentTitle(appDetail?.name || t('menus.appDetail', { ns: 'common' }))
useEffect(() => {
if (appDetail) {
@ -156,7 +158,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
if (!appDetail) {
return (
<div className='flex h-full items-center justify-center bg-background-body'>
<div className="flex h-full items-center justify-center bg-background-body">
<Loading />
</div>
)
@ -173,7 +175,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
{children}
</div>
{showTagManagementModal && (
<TagManagementModal type='app' show={showTagManagementModal} />
<TagManagementModal type="app" show={showTagManagementModal} />
)}
</div>
)

View File

@ -1,4 +1,4 @@
import React from 'react'
import * as React from 'react'
import Main from '@/app/components/app/log-annotation'
import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type'

View File

@ -1,30 +1,32 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useMemo } from 'react'
import type { IAppCardProps } from '@/app/components/app/overview/app-card'
import type { BlockEnum } from '@/app/components/workflow/types'
import type { UpdateAppSiteCodeResponse } from '@/models/app'
import type { App } from '@/types/app'
import type { I18nKeysByPrefix } from '@/types/i18n'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppCard from '@/app/components/app/overview/app-card'
import Loading from '@/app/components/base/loading'
import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
import TriggerCard from '@/app/components/app/overview/trigger-card'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { ToastContext } from '@/app/components/base/toast'
import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
import { isTriggerNode } from '@/app/components/workflow/types'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useDocLink } from '@/context/i18n'
import {
fetchAppDetail,
updateAppSiteAccessToken,
updateAppSiteConfig,
updateAppSiteStatus,
} from '@/service/apps'
import type { App } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import type { UpdateAppSiteCodeResponse } from '@/models/app'
import { asyncRunSafe } from '@/utils'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import type { IAppCardProps } from '@/app/components/app/overview/app-card'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppWorkflow } from '@/service/use-workflow'
import type { BlockEnum } from '@/app/components/workflow/types'
import { isTriggerNode } from '@/app/components/workflow/types'
import { useDocLink } from '@/context/i18n'
import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils'
export type ICardViewProps = {
appId: string
@ -59,30 +61,30 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const triggerDocUrl = docLink('/guides/workflow/node/start')
const buildTriggerModeMessage = useCallback((featureName: string) => (
<div className='flex flex-col gap-1'>
<div className='text-xs text-text-secondary'>
{t('appOverview.overview.disableTooltip.triggerMode', { feature: featureName })}
<div className="flex flex-col gap-1">
<div className="text-xs text-text-secondary">
{t('overview.disableTooltip.triggerMode', { ns: 'appOverview', feature: featureName })}
</div>
<div
className='cursor-pointer text-xs font-medium text-text-accent hover:underline'
className="cursor-pointer text-xs font-medium text-text-accent hover:underline"
onClick={(event) => {
event.stopPropagation()
window.open(triggerDocUrl, '_blank')
}}
>
{t('appOverview.overview.appInfo.enableTooltip.learnMore')}
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>
</div>
), [t, triggerDocUrl])
const disableWebAppTooltip = disableAppCards
? buildTriggerModeMessage(t('appOverview.overview.appInfo.title'))
? buildTriggerModeMessage(t('overview.appInfo.title', { ns: 'appOverview' }))
: null
const disableApiTooltip = disableAppCards
? buildTriggerModeMessage(t('appOverview.overview.apiInfo.title'))
? buildTriggerModeMessage(t('overview.apiInfo.title', { ns: 'appOverview' }))
: null
const disableMcpTooltip = disableAppCards
? buildTriggerModeMessage(t('tools.mcp.server.title'))
? buildTriggerModeMessage(t('mcp.server.title', { ns: 'tools' }))
: null
const updateAppDetail = async () => {
@ -93,7 +95,7 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
catch (error) { console.error(error) }
}
const handleCallbackResult = (err: Error | null, message?: string) => {
const handleCallbackResult = (err: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => {
const type = err ? 'error' : 'success'
message ||= (type === 'success' ? 'modifiedSuccessfully' : 'modifiedUnsuccessfully')
@ -103,7 +105,7 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
notify({
type,
message: t(`common.actionMsg.${message}`),
message: t(`actionMsg.${message}`, { ns: 'common' }) as string,
})
}
@ -185,12 +187,14 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
</>
)
const triggerCardNode = showTriggerCard ? (
<TriggerCard
appInfo={appDetail}
onToggleResult={handleCallbackResult}
/>
) : null
const triggerCardNode = showTriggerCard
? (
<TriggerCard
appInfo={appDetail}
onToggleResult={handleCallbackResult}
/>
)
: null
return (
<div className={className || 'mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'}>

View File

@ -1,21 +1,25 @@
'use client'
import React, { useState } from 'react'
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import type { I18nKeysByPrefix } from '@/types/i18n'
import dayjs from 'dayjs'
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import { TIME_PERIOD_MAPPING as LONG_TIME_PERIOD_MAPPING } from '@/app/components/app/log/filter'
import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/app-chart'
import { useStore as useAppStore } from '@/app/components/app/store'
import TimeRangePicker from './time-range-picker'
import { TIME_PERIOD_MAPPING as LONG_TIME_PERIOD_MAPPING } from '@/app/components/app/log/filter'
import { IS_CLOUD_EDITION } from '@/config'
import LongTimeRangePicker from './long-time-range-picker'
import TimeRangePicker from './time-range-picker'
dayjs.extend(quarterOfYear)
const today = dayjs()
const TIME_PERIOD_MAPPING = [
type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'>
const TIME_PERIOD_MAPPING: { value: number, name: TimePeriodName }[] = [
{ value: 0, name: 'today' },
{ value: 7, name: 'last7days' },
{ value: 30, name: 'last30days' },
@ -34,8 +38,8 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) {
const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow'
const isWorkflow = appDetail?.mode === 'workflow'
const [period, setPeriod] = useState<PeriodParams>(IS_CLOUD_EDITION
? { name: t('appLog.filter.period.today'), query: { start: today.startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } }
: { name: t('appLog.filter.period.last7days'), query: { start: today.subtract(7, 'day').startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } },
? { name: t('filter.period.today', { ns: 'appLog' }), query: { start: today.startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } }
: { name: t('filter.period.last7days', { ns: 'appLog' }), query: { start: today.subtract(7, 'day').startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } },
)
if (!appDetail)
@ -43,63 +47,65 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) {
return (
<div>
<div className='mb-4'>
<div className='system-xl-semibold mb-2 text-text-primary'>{t('common.appMenus.overview')}</div>
<div className='flex items-center justify-between'>
{IS_CLOUD_EDITION ? (
<TimeRangePicker
ranges={TIME_PERIOD_MAPPING}
onSelect={setPeriod}
queryDateFormat={queryDateFormat}
/>
) : (
<LongTimeRangePicker
periodMapping={LONG_TIME_PERIOD_MAPPING}
onSelect={setPeriod}
queryDateFormat={queryDateFormat}
/>
)}
<div className="mb-4">
<div className="system-xl-semibold mb-2 text-text-primary">{t('appMenus.overview', { ns: 'common' })}</div>
<div className="flex items-center justify-between">
{IS_CLOUD_EDITION
? (
<TimeRangePicker
ranges={TIME_PERIOD_MAPPING}
onSelect={setPeriod}
queryDateFormat={queryDateFormat}
/>
)
: (
<LongTimeRangePicker
periodMapping={LONG_TIME_PERIOD_MAPPING}
onSelect={setPeriod}
queryDateFormat={queryDateFormat}
/>
)}
{headerRight}
</div>
</div>
{!isWorkflow && (
<div className='mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'>
<div className="mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2">
<ConversationsChart period={period} id={appId} />
<EndUsersChart period={period} id={appId} />
</div>
)}
{!isWorkflow && (
<div className='mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'>
<div className="mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2">
{isChatApp
? (
<AvgSessionInteractions period={period} id={appId} />
)
<AvgSessionInteractions period={period} id={appId} />
)
: (
<AvgResponseTime period={period} id={appId} />
)}
<AvgResponseTime period={period} id={appId} />
)}
<TokenPerSecond period={period} id={appId} />
</div>
)}
{!isWorkflow && (
<div className='mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'>
<div className="mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2">
<UserSatisfactionRate period={period} id={appId} />
<CostChart period={period} id={appId} />
</div>
)}
{!isWorkflow && isChatApp && (
<div className='mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'>
<div className="mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2">
<MessagesChart period={period} id={appId} />
</div>
)}
{isWorkflow && (
<div className='mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'>
<div className="mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2">
<WorkflowMessagesChart period={period} id={appId} />
<WorkflowDailyTerminalsChart period={period} id={appId} />
</div>
)}
{isWorkflow && (
<div className='mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'>
<div className="mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2">
<WorkflowCostChart period={period} id={appId} />
<AvgUserInteractions period={period} id={appId} />
</div>

View File

@ -1,13 +1,17 @@
'use client'
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import type { FC } from 'react'
import React from 'react'
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import type { Item } from '@/app/components/base/select'
import { SimpleSelect } from '@/app/components/base/select'
import { useTranslation } from 'react-i18next'
import type { I18nKeysByPrefix } from '@/types/i18n'
import dayjs from 'dayjs'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleSelect } from '@/app/components/base/select'
type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'>
type Props = {
periodMapping: { [key: string]: { value: number; name: string } }
periodMapping: { [key: string]: { value: number, name: TimePeriodName } }
onSelect: (payload: PeriodParams) => void
queryDateFormat: string
}
@ -24,9 +28,9 @@ const LongTimeRangePicker: FC<Props> = ({
const handleSelect = React.useCallback((item: Item) => {
const id = item.value
const value = periodMapping[id]?.value ?? '-1'
const name = item.name || t('appLog.filter.period.allTime')
const name = item.name || t('filter.period.allTime', { ns: 'appLog' })
if (value === -1) {
onSelect({ name: t('appLog.filter.period.allTime'), query: undefined })
onSelect({ name: t('filter.period.allTime', { ns: 'appLog' }), query: undefined })
}
else if (value === 0) {
const startOfToday = today.startOf('day').format(queryDateFormat)
@ -52,11 +56,11 @@ const LongTimeRangePicker: FC<Props> = ({
return (
<SimpleSelect
items={Object.entries(periodMapping).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}`) }))}
className='mt-0 !w-40'
items={Object.entries(periodMapping).map(([k, v]) => ({ value: k, name: t(`filter.period.${v.name}`, { ns: 'appLog' }) }))}
className="mt-0 !w-40"
notClearable={true}
onSelect={handleSelect}
defaultValue={'2'}
defaultValue="2"
/>
)
}

View File

@ -1,7 +1,7 @@
import React from 'react'
import * as React from 'react'
import ApikeyInfoPanel from '@/app/components/app/overview/apikey-info-panel'
import ChartView from './chart-view'
import TracingPanel from './tracing/panel'
import ApikeyInfoPanel from '@/app/components/app/overview/apikey-info-panel'
export type IDevelopProps = {
params: Promise<{ appId: string }>

View File

@ -1,15 +1,16 @@
'use client'
import { RiCalendarLine } from '@remixicon/react'
import type { Dayjs } from 'dayjs'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import cn from '@/utils/classnames'
import { formatToLocalTime } from '@/utils/format'
import { useI18N } from '@/context/i18n'
import Picker from '@/app/components/base/date-and-time-picker/date-picker'
import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types'
import { noop } from 'lodash-es'
import { RiCalendarLine } from '@remixicon/react'
import dayjs from 'dayjs'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useCallback } from 'react'
import Picker from '@/app/components/base/date-and-time-picker/date-picker'
import { useI18N } from '@/context/i18n'
import { cn } from '@/utils/classnames'
import { formatToLocalTime } from '@/utils/format'
type Props = {
start: Dayjs
@ -50,9 +51,9 @@ const DatePicker: FC<Props> = ({
}, [availableEndDate, start])
return (
<div className='flex h-8 items-center space-x-0.5 rounded-lg bg-components-input-bg-normal px-2'>
<div className='p-px'>
<RiCalendarLine className='size-3.5 text-text-tertiary' />
<div className="flex h-8 items-center space-x-0.5 rounded-lg bg-components-input-bg-normal px-2">
<div className="p-px">
<RiCalendarLine className="size-3.5 text-text-tertiary" />
</div>
<Picker
value={start}
@ -63,7 +64,7 @@ const DatePicker: FC<Props> = ({
noConfirm
getIsDateDisabled={startDateDisabled}
/>
<span className='system-sm-regular text-text-tertiary'>-</span>
<span className="system-sm-regular text-text-tertiary">-</span>
<Picker
value={end}
onChange={onEndChange}

View File

@ -1,19 +1,23 @@
'use client'
import type { PeriodParams, PeriodParamsWithTimeRange } from '@/app/components/app/overview/app-chart'
import type { FC } from 'react'
import React, { useCallback, useState } from 'react'
import type { Dayjs } from 'dayjs'
import { HourglassShape } from '@/app/components/base/icons/src/vender/other'
import RangeSelector from './range-selector'
import DatePicker from './date-picker'
import type { FC } from 'react'
import type { PeriodParams, PeriodParamsWithTimeRange } from '@/app/components/app/overview/app-chart'
import type { I18nKeysByPrefix } from '@/types/i18n'
import dayjs from 'dayjs'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { HourglassShape } from '@/app/components/base/icons/src/vender/other'
import { useI18N } from '@/context/i18n'
import { formatToLocalTime } from '@/utils/format'
import DatePicker from './date-picker'
import RangeSelector from './range-selector'
const today = dayjs()
type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'>
type Props = {
ranges: { value: number; name: string }[]
ranges: { value: number, name: TimePeriodName }[]
onSelect: (payload: PeriodParams) => void
queryDateFormat: string
}
@ -44,9 +48,12 @@ const TimeRangePicker: FC<Props> = ({
const handleDateChange = useCallback((type: 'start' | 'end') => {
return (date?: Dayjs) => {
if (!date) return
if (type === 'start' && date.isSame(start)) return
if (type === 'end' && date.isSame(end)) return
if (!date)
return
if (type === 'start' && date.isSame(start))
return
if (type === 'end' && date.isSame(end))
return
if (type === 'start')
setStart(date)
else
@ -67,13 +74,13 @@ const TimeRangePicker: FC<Props> = ({
}, [start, end, onSelect, locale, queryDateFormat])
return (
<div className='flex items-center'>
<div className="flex items-center">
<RangeSelector
isCustomRange={isCustomRange}
ranges={ranges}
onSelect={handleRangeChange}
/>
<HourglassShape className='h-3.5 w-2 text-components-input-bg-normal' />
<HourglassShape className="h-3.5 w-2 text-components-input-bg-normal" />
<DatePicker
start={start}
end={end}

View File

@ -1,19 +1,23 @@
'use client'
import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { SimpleSelect } from '@/app/components/base/select'
import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart'
import type { Item } from '@/app/components/base/select'
import dayjs from 'dayjs'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import dayjs from 'dayjs'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleSelect } from '@/app/components/base/select'
import { cn } from '@/utils/classnames'
const today = dayjs()
type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'>
type Props = {
isCustomRange: boolean
ranges: { value: number; name: string }[]
ranges: { value: number, name: TimePeriodName }[]
onSelect: (payload: PeriodParamsWithTimeRange) => void
}
@ -41,13 +45,13 @@ const RangeSelector: FC<Props> = ({
const renderTrigger = useCallback((item: Item | null, isOpen: boolean) => {
return (
<div className={cn('flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pl-3 pr-2', isOpen && 'bg-state-base-hover-alt')}>
<div className='system-sm-regular text-components-input-text-filled'>{isCustomRange ? t('appLog.filter.period.custom') : item?.name}</div>
<div className="system-sm-regular text-components-input-text-filled">{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : item?.name}</div>
<RiArrowDownSLine className={cn('size-4 text-text-quaternary', isOpen && 'text-text-secondary')} />
</div>
)
}, [isCustomRange])
const renderOption = useCallback(({ item, selected }: { item: Item; selected: boolean }) => {
const renderOption = useCallback(({ item, selected }: { item: Item, selected: boolean }) => {
return (
<>
{selected && (
@ -65,15 +69,15 @@ const RangeSelector: FC<Props> = ({
}, [])
return (
<SimpleSelect
items={ranges.map(v => ({ ...v, name: t(`appLog.filter.period.${v.name}`) }))}
className='mt-0 !w-40'
items={ranges.map(v => ({ ...v, name: t(`filter.period.${v.name}`, { ns: 'appLog' }) }))}
className="mt-0 !w-40"
notClearable={true}
onSelect={handleSelectRange}
defaultValue={0}
wrapperClassName='h-8'
optionWrapClassName='w-[200px] translate-x-[-24px]'
wrapperClassName="h-8"
optionWrapClassName="w-[200px] translate-x-[-24px]"
renderTrigger={renderTrigger}
optionClassName='flex items-center py-0 pl-7 pr-2 h-8'
optionClassName="flex items-center py-0 pl-7 pr-2 h-8"
renderOption={renderOption}
/>
)

View File

@ -1,7 +1,8 @@
import React from 'react'
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import * as React from 'react'
import { OpikIconBig } from '@/app/components/base/icons/src/public/tracing'
import iconData from '@/app/components/base/icons/src/public/tracing/OpikIconBig.json'
import { normalizeAttrs } from '@/app/components/base/icons/utils'
describe('SVG Attribute Error Reproduction', () => {
// Capture console errors
@ -10,7 +11,7 @@ describe('SVG Attribute Error Reproduction', () => {
beforeEach(() => {
errorMessages = []
console.error = jest.fn((message) => {
console.error = vi.fn((message) => {
errorMessages.push(message)
originalError(message)
})
@ -54,9 +55,6 @@ describe('SVG Attribute Error Reproduction', () => {
it('should analyze the SVG structure causing the errors', () => {
console.log('\n=== ANALYZING SVG STRUCTURE ===')
// Import the JSON data directly
const iconData = require('@/app/components/base/icons/src/public/tracing/OpikIconBig.json')
console.log('Icon structure analysis:')
console.log('- Root element:', iconData.icon.name)
console.log('- Children count:', iconData.icon.children?.length || 0)
@ -113,8 +111,6 @@ describe('SVG Attribute Error Reproduction', () => {
it('should test the normalizeAttrs function behavior', () => {
console.log('\n=== TESTING normalizeAttrs FUNCTION ===')
const { normalizeAttrs } = require('@/app/components/base/icons/utils')
const testAttributes = {
'inkscape:showpageshadow': '2',
'inkscape:pageopacity': '0.0',

View File

@ -1,15 +1,16 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import type { PopupProps } from './config-popup'
import ConfigPopup from './config-popup'
import cn from '@/utils/classnames'
import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { cn } from '@/utils/classnames'
import ConfigPopup from './config-popup'
type Props = {
readOnly: boolean
@ -42,7 +43,7 @@ const ConfigBtn: FC<Props> = ({
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
placement="bottom-end"
offset={12}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
@ -50,7 +51,7 @@ const ConfigBtn: FC<Props> = ({
{children}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[11]'>
<PortalToFollowElemContent className="z-[11]">
<ConfigPopup {...popupProps} />
</PortalToFollowElemContent>
</PortalToFollowElem>

View File

@ -1,20 +1,21 @@
'use client'
import type { FC, JSX } from 'react'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import TracingIcon from './tracing-icon'
import ProviderPanel from './provider-panel'
import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type'
import { TracingProvider } from './type'
import ProviderConfigModal from './provider-config-modal'
import Indicator from '@/app/components/header/indicator'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import Divider from '@/app/components/base/divider'
import cn from '@/utils/classnames'
import Indicator from '@/app/components/header/indicator'
import { cn } from '@/utils/classnames'
import ProviderConfigModal from './provider-config-modal'
import ProviderPanel from './provider-panel'
import TracingIcon from './tracing-icon'
import { TracingProvider } from './type'
const I18N_PREFIX = 'app.tracing'
const I18N_PREFIX = 'tracing'
export type PopupProps = {
appId: string
@ -92,7 +93,7 @@ const ConfigPopup: FC<PopupProps> = ({
const switchContent = (
<Switch
className='ml-3'
className="ml-3"
defaultValue={enabled}
onChange={onStatusChange}
disabled={providerAllNotConfigured}
@ -322,68 +323,68 @@ const ConfigPopup: FC<PopupProps> = ({
}
return (
<div className='w-[420px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-xl'>
<div className='flex items-center justify-between'>
<div className='flex items-center'>
<TracingIcon size='md' className='mr-2' />
<div className='title-2xl-semi-bold text-text-primary'>{t(`${I18N_PREFIX}.tracing`)}</div>
<div className="w-[420px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-xl">
<div className="flex items-center justify-between">
<div className="flex items-center">
<TracingIcon size="md" className="mr-2" />
<div className="title-2xl-semi-bold text-text-primary">{t(`${I18N_PREFIX}.tracing`, { ns: 'app' })}</div>
</div>
<div className='flex items-center'>
<div className="flex items-center">
<Indicator color={enabled ? 'green' : 'gray'} />
<div className={cn('system-xs-semibold-uppercase ml-1 text-text-tertiary', enabled && 'text-util-colors-green-green-600')}>
{t(`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`)}
{t(`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`, { ns: 'app' })}
</div>
{!readOnly && (
<>
{providerAllNotConfigured
? (
<Tooltip
popupContent={t(`${I18N_PREFIX}.disabledTip`)}
>
{switchContent}
</Tooltip>
)
<Tooltip
popupContent={t(`${I18N_PREFIX}.disabledTip`, { ns: 'app' })}
>
{switchContent}
</Tooltip>
)
: switchContent}
</>
)}
</div>
</div>
<div className='system-xs-regular mt-2 text-text-tertiary'>
{t(`${I18N_PREFIX}.tracingDescription`)}
<div className="system-xs-regular mt-2 text-text-tertiary">
{t(`${I18N_PREFIX}.tracingDescription`, { ns: 'app' })}
</div>
<Divider className='my-3' />
<div className='relative'>
<Divider className="my-3" />
<div className="relative">
{(providerAllConfigured || providerAllNotConfigured)
? (
<>
<div className='system-xs-medium-uppercase text-text-tertiary'>{t(`${I18N_PREFIX}.configProviderTitle.${providerAllConfigured ? 'configured' : 'notConfigured'}`)}</div>
<div className='mt-2 max-h-96 space-y-2 overflow-y-auto'>
{langfusePanel}
{langSmithPanel}
{opikPanel}
{mlflowPanel}
{databricksPanel}
{weavePanel}
{arizePanel}
{phoenixPanel}
{aliyunPanel}
{tencentPanel}
</div>
</>
)
<>
<div className="system-xs-medium-uppercase text-text-tertiary">{t(`${I18N_PREFIX}.configProviderTitle.${providerAllConfigured ? 'configured' : 'notConfigured'}`, { ns: 'app' })}</div>
<div className="mt-2 max-h-96 space-y-2 overflow-y-auto">
{langfusePanel}
{langSmithPanel}
{opikPanel}
{mlflowPanel}
{databricksPanel}
{weavePanel}
{arizePanel}
{phoenixPanel}
{aliyunPanel}
{tencentPanel}
</div>
</>
)
: (
<>
<div className='system-xs-medium-uppercase text-text-tertiary'>{t(`${I18N_PREFIX}.configProviderTitle.configured`)}</div>
<div className='mt-2 max-h-40 space-y-2 overflow-y-auto'>
{configuredProviderPanel()}
</div>
<div className='system-xs-medium-uppercase mt-3 text-text-tertiary'>{t(`${I18N_PREFIX}.configProviderTitle.moreProvider`)}</div>
<div className='mt-2 max-h-40 space-y-2 overflow-y-auto'>
{moreProviderPanel()}
</div>
</>
)}
<>
<div className="system-xs-medium-uppercase text-text-tertiary">{t(`${I18N_PREFIX}.configProviderTitle.configured`, { ns: 'app' })}</div>
<div className="mt-2 max-h-40 space-y-2 overflow-y-auto">
{configuredProviderPanel()}
</div>
<div className="system-xs-medium-uppercase mt-3 text-text-tertiary">{t(`${I18N_PREFIX}.configProviderTitle.moreProvider`, { ns: 'app' })}</div>
<div className="mt-2 max-h-40 space-y-2 overflow-y-auto">
{moreProviderPanel()}
</div>
</>
)}
</div>
{isShowConfigModal && (

View File

@ -1,8 +1,8 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import * as React from 'react'
import Input from '@/app/components/base/input'
import { cn } from '@/utils/classnames'
type Props = {
className?: string
@ -25,14 +25,17 @@ const Field: FC<Props> = ({
}) => {
return (
<div className={cn(className)}>
<div className='flex py-[7px]'>
<div className={cn(labelClassName, 'flex h-[18px] items-center text-[13px] font-medium text-text-primary')}>{label} </div>
{isRequired && <span className='ml-0.5 text-xs font-semibold text-[#D92D20]'>*</span>}
<div className="flex py-[7px]">
<div className={cn(labelClassName, 'flex h-[18px] items-center text-[13px] font-medium text-text-primary')}>
{label}
{' '}
</div>
{isRequired && <span className="ml-0.5 text-xs font-semibold text-[#D92D20]">*</span>}
</div>
<Input
value={value}
onChange={e => onChange(e.target.value)}
className='h-9'
className="h-9"
placeholder={placeholder}
/>
</div>

View File

@ -1,28 +1,29 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type'
import type { TracingStatus } from '@/models/app'
import {
RiArrowDownDoubleLine,
RiEqualizer2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { usePathname } from 'next/navigation'
import { useBoolean } from 'ahooks'
import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type'
import { TracingProvider } from './type'
import TracingIcon from './tracing-icon'
import ConfigButton from './config-button'
import cn from '@/utils/classnames'
import { AliyunIcon, ArizeIcon, DatabricksIcon, LangfuseIcon, LangsmithIcon, MlflowIcon, OpikIcon, PhoenixIcon, TencentIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing'
import Indicator from '@/app/components/header/indicator'
import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps'
import type { TracingStatus } from '@/models/app'
import Toast from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
import Loading from '@/app/components/base/loading'
import { usePathname } from 'next/navigation'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { AliyunIcon, ArizeIcon, DatabricksIcon, LangfuseIcon, LangsmithIcon, MlflowIcon, OpikIcon, PhoenixIcon, TencentIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import Indicator from '@/app/components/header/indicator'
import { useAppContext } from '@/context/app-context'
import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps'
import { cn } from '@/utils/classnames'
import ConfigButton from './config-button'
import TracingIcon from './tracing-icon'
import { TracingProvider } from './type'
const I18N_PREFIX = 'app.tracing'
const I18N_PREFIX = 'tracing'
const Panel: FC = () => {
const { t } = useTranslation()
@ -44,7 +45,7 @@ const Panel: FC = () => {
if (!noToast) {
Toast.notify({
type: 'success',
message: t('common.api.success'),
message: t('api.success', { ns: 'common' }),
})
}
}
@ -215,8 +216,8 @@ const Panel: FC = () => {
if (!isLoaded) {
return (
<div className='mb-3 flex items-center justify-between'>
<div className='w-[200px]'>
<div className="mb-3 flex items-center justify-between">
<div className="w-[200px]">
<Loading />
</div>
</div>
@ -252,14 +253,14 @@ const Panel: FC = () => {
'flex cursor-pointer select-none items-center rounded-xl border-l-[0.5px] border-t border-effects-highlight bg-background-default-dodge p-2 shadow-xs hover:border-effects-highlight-lightmode-off hover:bg-background-default-lighter',
)}
>
<TracingIcon size='md' />
<div className='system-sm-semibold mx-2 text-text-secondary'>{t(`${I18N_PREFIX}.title`)}</div>
<div className='rounded-md p-1'>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
<TracingIcon size="md" />
<div className="system-sm-semibold mx-2 text-text-secondary">{t(`${I18N_PREFIX}.title`, { ns: 'app' })}</div>
<div className="rounded-md p-1">
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
</div>
<Divider type='vertical' className='h-3.5' />
<div className='rounded-md p-1'>
<RiArrowDownDoubleLine className='h-4 w-4 text-text-tertiary' />
<Divider type="vertical" className="h-3.5" />
<div className="rounded-md p-1">
<RiArrowDownDoubleLine className="h-4 w-4 text-text-tertiary" />
</div>
</div>
</ConfigButton>
@ -291,17 +292,17 @@ const Panel: FC = () => {
'flex cursor-pointer select-none items-center rounded-xl border-l-[0.5px] border-t border-effects-highlight bg-background-default-dodge p-2 shadow-xs hover:border-effects-highlight-lightmode-off hover:bg-background-default-lighter',
)}
>
<div className='ml-4 mr-1 flex items-center'>
<div className="ml-4 mr-1 flex items-center">
<Indicator color={enabled ? 'green' : 'gray'} />
<div className='system-xs-semibold-uppercase ml-1.5 text-text-tertiary'>
{t(`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`)}
<div className="system-xs-semibold-uppercase ml-1.5 text-text-tertiary">
{t(`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`, { ns: 'app' })}
</div>
</div>
{InUseProviderIcon && <InUseProviderIcon className='ml-1 h-4' />}
<div className='ml-2 rounded-md p-1'>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
{InUseProviderIcon && <InUseProviderIcon className="ml-1 h-4" />}
<div className="ml-2 rounded-md p-1">
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
</div>
<Divider type='vertical' className='h-3.5' />
<Divider type="vertical" className="h-3.5" />
</div>
</ConfigButton>
)}

View File

@ -1,23 +1,24 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import Field from './field'
import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type'
import { TracingProvider } from './type'
import { docURL } from './config'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import Divider from '@/app/components/base/divider'
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
import {
PortalToFollowElem,
PortalToFollowElemContent,
} from '@/app/components/base/portal-to-follow-elem'
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
import Button from '@/app/components/base/button'
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
import Confirm from '@/app/components/base/confirm'
import { addTracingConfig, removeTracingConfig, updateTracingConfig } from '@/service/apps'
import Toast from '@/app/components/base/toast'
import Divider from '@/app/components/base/divider'
import { addTracingConfig, removeTracingConfig, updateTracingConfig } from '@/service/apps'
import { docURL } from './config'
import Field from './field'
import { TracingProvider } from './type'
type Props = {
appId: string
@ -29,7 +30,7 @@ type Props = {
onChosen: (provider: TracingProvider) => void
}
const I18N_PREFIX = 'app.tracing.configProvider'
const I18N_PREFIX = 'tracing.configProvider'
const arizeConfigTemplate = {
api_key: '',
@ -156,7 +157,7 @@ const ProviderConfigModal: FC<Props> = ({
})
Toast.notify({
type: 'success',
message: t('common.api.remove'),
message: t('api.remove', { ns: 'common' }),
})
onRemoved()
hideRemoveConfirm()
@ -176,37 +177,37 @@ const ProviderConfigModal: FC<Props> = ({
if (type === TracingProvider.arize) {
const postData = config as ArizeConfig
if (!postData.api_key)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'API Key' })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: 'API Key' })
if (!postData.space_id)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Space ID' })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: 'Space ID' })
if (!errorMessage && !postData.project)
errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.project`) })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t(`${I18N_PREFIX}.project`, { ns: 'app' }) })
}
if (type === TracingProvider.phoenix) {
const postData = config as PhoenixConfig
if (!postData.api_key)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'API Key' })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: 'API Key' })
if (!errorMessage && !postData.project)
errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.project`) })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t(`${I18N_PREFIX}.project`, { ns: 'app' }) })
}
if (type === TracingProvider.langSmith) {
const postData = config as LangSmithConfig
if (!postData.api_key)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'API Key' })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: 'API Key' })
if (!errorMessage && !postData.project)
errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.project`) })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t(`${I18N_PREFIX}.project`, { ns: 'app' }) })
}
if (type === TracingProvider.langfuse) {
const postData = config as LangFuseConfig
if (!errorMessage && !postData.secret_key)
errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.secretKey`) })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t(`${I18N_PREFIX}.secretKey`, { ns: 'app' }) })
if (!errorMessage && !postData.public_key)
errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.publicKey`) })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t(`${I18N_PREFIX}.publicKey`, { ns: 'app' }) })
if (!errorMessage && !postData.host)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Host' })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: 'Host' })
}
if (type === TracingProvider.opik) {
@ -217,43 +218,43 @@ const ProviderConfigModal: FC<Props> = ({
if (type === TracingProvider.weave) {
const postData = config as WeaveConfig
if (!errorMessage && !postData.api_key)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'API Key' })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: 'API Key' })
if (!errorMessage && !postData.project)
errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.project`) })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t(`${I18N_PREFIX}.project`, { ns: 'app' }) })
}
if (type === TracingProvider.aliyun) {
const postData = config as AliyunConfig
if (!errorMessage && !postData.app_name)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'App Name' })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: 'App Name' })
if (!errorMessage && !postData.license_key)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'License Key' })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: 'License Key' })
if (!errorMessage && !postData.endpoint)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Endpoint' })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: 'Endpoint' })
}
if (type === TracingProvider.mlflow) {
const postData = config as MLflowConfig
if (!errorMessage && !postData.tracking_uri)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Tracking URI' })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: 'Tracking URI' })
}
if (type === TracingProvider.databricks) {
const postData = config as DatabricksConfig
if (!errorMessage && !postData.experiment_id)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Experiment ID' })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: 'Experiment ID' })
if (!errorMessage && !postData.host)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Host' })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: 'Host' })
}
if (type === TracingProvider.tencent) {
const postData = config as TencentConfig
if (!errorMessage && !postData.token)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Token' })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: 'Token' })
if (!errorMessage && !postData.endpoint)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Endpoint' })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: 'Endpoint' })
if (!errorMessage && !postData.service_name)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Service Name' })
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: 'Service Name' })
}
return errorMessage
@ -280,7 +281,7 @@ const ProviderConfigModal: FC<Props> = ({
})
Toast.notify({
type: 'success',
message: t('common.api.success'),
message: t('api.success', { ns: 'common' }),
})
onSaved(config)
if (isAdd)
@ -295,403 +296,407 @@ const ProviderConfigModal: FC<Props> = ({
<>
{!isShowRemoveConfirm
? (
<PortalToFollowElem open>
<PortalToFollowElemContent className='z-[60] h-full w-full'>
<div className='fixed inset-0 flex items-center justify-center bg-background-overlay'>
<div className='mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-components-panel-bg shadow-xl'>
<div className='px-8 pt-8'>
<div className='mb-4 flex items-center justify-between'>
<div className='title-2xl-semi-bold text-text-primary'>{t(`${I18N_PREFIX}.title`)}{t(`app.tracing.${type}.title`)}</div>
</div>
<div className='space-y-4'>
{type === TracingProvider.arize && (
<>
<Field
label='API Key'
labelClassName='!text-sm'
isRequired
value={(config as ArizeConfig).api_key}
onChange={handleConfigChange('api_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'API Key' })!}
/>
<Field
label='Space ID'
labelClassName='!text-sm'
isRequired
value={(config as ArizeConfig).space_id}
onChange={handleConfigChange('space_id')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'Space ID' })!}
/>
<Field
label={t(`${I18N_PREFIX}.project`)!}
labelClassName='!text-sm'
isRequired
value={(config as ArizeConfig).project}
onChange={handleConfigChange('project')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.project`) })!}
/>
<Field
label='Endpoint'
labelClassName='!text-sm'
value={(config as ArizeConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder={'https://otlp.arize.com'}
/>
</>
)}
{type === TracingProvider.phoenix && (
<>
<Field
label='API Key'
labelClassName='!text-sm'
isRequired
value={(config as PhoenixConfig).api_key}
onChange={handleConfigChange('api_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'API Key' })!}
/>
<Field
label={t(`${I18N_PREFIX}.project`)!}
labelClassName='!text-sm'
isRequired
value={(config as PhoenixConfig).project}
onChange={handleConfigChange('project')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.project`) })!}
/>
<Field
label='Endpoint'
labelClassName='!text-sm'
value={(config as PhoenixConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder={'https://app.phoenix.arize.com'}
/>
</>
)}
{type === TracingProvider.aliyun && (
<>
<Field
label='License Key'
labelClassName='!text-sm'
isRequired
value={(config as AliyunConfig).license_key}
onChange={handleConfigChange('license_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'License Key' })!}
/>
<Field
label='Endpoint'
labelClassName='!text-sm'
value={(config as AliyunConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder={'https://tracing.arms.aliyuncs.com'}
/>
<Field
label='App Name'
labelClassName='!text-sm'
value={(config as AliyunConfig).app_name}
onChange={handleConfigChange('app_name')}
/>
</>
)}
{type === TracingProvider.tencent && (
<>
<Field
label='Token'
labelClassName='!text-sm'
isRequired
value={(config as TencentConfig).token}
onChange={handleConfigChange('token')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'Token' })!}
/>
<Field
label='Endpoint'
labelClassName='!text-sm'
isRequired
value={(config as TencentConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder='https://your-region.cls.tencentcs.com'
/>
<Field
label='Service Name'
labelClassName='!text-sm'
isRequired
value={(config as TencentConfig).service_name}
onChange={handleConfigChange('service_name')}
placeholder='dify_app'
/>
</>
)}
{type === TracingProvider.weave && (
<>
<Field
label='API Key'
labelClassName='!text-sm'
isRequired
value={(config as WeaveConfig).api_key}
onChange={handleConfigChange('api_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'API Key' })!}
/>
<Field
label={t(`${I18N_PREFIX}.project`)!}
labelClassName='!text-sm'
isRequired
value={(config as WeaveConfig).project}
onChange={handleConfigChange('project')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.project`) })!}
/>
<Field
label='Entity'
labelClassName='!text-sm'
value={(config as WeaveConfig).entity}
onChange={handleConfigChange('entity')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'Entity' })!}
/>
<Field
label='Endpoint'
labelClassName='!text-sm'
value={(config as WeaveConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder={'https://trace.wandb.ai/'}
/>
<Field
label='Host'
labelClassName='!text-sm'
value={(config as WeaveConfig).host}
onChange={handleConfigChange('host')}
placeholder={'https://api.wandb.ai'}
/>
</>
)}
{type === TracingProvider.langSmith && (
<>
<Field
label='API Key'
labelClassName='!text-sm'
isRequired
value={(config as LangSmithConfig).api_key}
onChange={handleConfigChange('api_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'API Key' })!}
/>
<Field
label={t(`${I18N_PREFIX}.project`)!}
labelClassName='!text-sm'
isRequired
value={(config as LangSmithConfig).project}
onChange={handleConfigChange('project')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.project`) })!}
/>
<Field
label='Endpoint'
labelClassName='!text-sm'
value={(config as LangSmithConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder={'https://api.smith.langchain.com'}
/>
</>
)}
{type === TracingProvider.langfuse && (
<>
<Field
label={t(`${I18N_PREFIX}.secretKey`)!}
labelClassName='!text-sm'
value={(config as LangFuseConfig).secret_key}
isRequired
onChange={handleConfigChange('secret_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.secretKey`) })!}
/>
<Field
label={t(`${I18N_PREFIX}.publicKey`)!}
labelClassName='!text-sm'
isRequired
value={(config as LangFuseConfig).public_key}
onChange={handleConfigChange('public_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.publicKey`) })!}
/>
<Field
label='Host'
labelClassName='!text-sm'
isRequired
value={(config as LangFuseConfig).host}
onChange={handleConfigChange('host')}
placeholder='https://cloud.langfuse.com'
/>
</>
)}
{type === TracingProvider.opik && (
<>
<Field
label='API Key'
labelClassName='!text-sm'
value={(config as OpikConfig).api_key}
onChange={handleConfigChange('api_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'API Key' })!}
/>
<Field
label={t(`${I18N_PREFIX}.project`)!}
labelClassName='!text-sm'
value={(config as OpikConfig).project}
onChange={handleConfigChange('project')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.project`) })!}
/>
<Field
label='Workspace'
labelClassName='!text-sm'
value={(config as OpikConfig).workspace}
onChange={handleConfigChange('workspace')}
placeholder={'default'}
/>
<Field
label='Url'
labelClassName='!text-sm'
value={(config as OpikConfig).url}
onChange={handleConfigChange('url')}
placeholder={'https://www.comet.com/opik/api/'}
/>
</>
)}
{type === TracingProvider.mlflow && (
<>
<Field
label={t(`${I18N_PREFIX}.trackingUri`)!}
labelClassName='!text-sm'
value={(config as MLflowConfig).tracking_uri}
isRequired
onChange={handleConfigChange('tracking_uri')}
placeholder={'http://localhost:5000'}
/>
<Field
label={t(`${I18N_PREFIX}.experimentId`)!}
labelClassName='!text-sm'
isRequired
value={(config as MLflowConfig).experiment_id}
onChange={handleConfigChange('experiment_id')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.experimentId`) })!}
/>
<Field
label={t(`${I18N_PREFIX}.username`)!}
labelClassName='!text-sm'
value={(config as MLflowConfig).username}
onChange={handleConfigChange('username')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.username`) })!}
/>
<Field
label={t(`${I18N_PREFIX}.password`)!}
labelClassName='!text-sm'
value={(config as MLflowConfig).password}
onChange={handleConfigChange('password')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.password`) })!}
/>
</>
)}
{type === TracingProvider.databricks && (
<>
<Field
label={t(`${I18N_PREFIX}.experimentId`)!}
labelClassName='!text-sm'
value={(config as DatabricksConfig).experiment_id}
onChange={handleConfigChange('experiment_id')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.experimentId`) })!}
isRequired
/>
<Field
label={t(`${I18N_PREFIX}.databricksHost`)!}
labelClassName='!text-sm'
value={(config as DatabricksConfig).host}
onChange={handleConfigChange('host')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.databricksHost`) })!}
isRequired
/>
<Field
label={t(`${I18N_PREFIX}.clientId`)!}
labelClassName='!text-sm'
value={(config as DatabricksConfig).client_id}
onChange={handleConfigChange('client_id')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.clientId`) })!}
/>
<Field
label={t(`${I18N_PREFIX}.clientSecret`)!}
labelClassName='!text-sm'
value={(config as DatabricksConfig).client_secret}
onChange={handleConfigChange('client_secret')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.clientSecret`) })!}
/>
<Field
label={t(`${I18N_PREFIX}.personalAccessToken`)!}
labelClassName='!text-sm'
value={(config as DatabricksConfig).personal_access_token}
onChange={handleConfigChange('personal_access_token')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.personalAccessToken`) })!}
/>
</>
)}
</div>
<div className='my-8 flex h-8 items-center justify-between'>
<a
className='flex items-center space-x-1 text-xs font-normal leading-[18px] text-[#155EEF]'
target='_blank'
href={docURL[type]}
>
<span>{t(`${I18N_PREFIX}.viewDocsLink`, { key: t(`app.tracing.${type}.title`) })}</span>
<LinkExternal02 className='h-3 w-3' />
</a>
<div className='flex items-center'>
{isEdit && (
<>
<Button
className='h-9 text-sm font-medium text-text-secondary'
onClick={showRemoveConfirm}
>
<span className='text-[#D92D20]'>{t('common.operation.remove')}</span>
</Button>
<Divider type='vertical' className='mx-3 h-[18px]' />
</>
)}
<Button
className='mr-2 h-9 text-sm font-medium text-text-secondary'
onClick={onCancel}
>
{t('common.operation.cancel')}
</Button>
<Button
className='h-9 text-sm font-medium'
variant='primary'
onClick={handleSave}
loading={isSaving}
>
{t(`common.operation.${isAdd ? 'saveAndEnable' : 'save'}`)}
</Button>
<PortalToFollowElem open>
<PortalToFollowElemContent className="z-[60] h-full w-full">
<div className="fixed inset-0 flex items-center justify-center bg-background-overlay">
<div className="mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-components-panel-bg shadow-xl">
<div className="px-8 pt-8">
<div className="mb-4 flex items-center justify-between">
<div className="title-2xl-semi-bold text-text-primary">
{t(`${I18N_PREFIX}.title`, { ns: 'app' })}
{t(`tracing.${type}.title`, { ns: 'app' })}
</div>
</div>
<div className="space-y-4">
{type === TracingProvider.arize && (
<>
<Field
label="API Key"
labelClassName="!text-sm"
isRequired
value={(config as ArizeConfig).api_key}
onChange={handleConfigChange('api_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: 'API Key' })!}
/>
<Field
label="Space ID"
labelClassName="!text-sm"
isRequired
value={(config as ArizeConfig).space_id}
onChange={handleConfigChange('space_id')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: 'Space ID' })!}
/>
<Field
label={t(`${I18N_PREFIX}.project`, { ns: 'app' })!}
labelClassName="!text-sm"
isRequired
value={(config as ArizeConfig).project}
onChange={handleConfigChange('project')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: t(`${I18N_PREFIX}.project`, { ns: 'app' }) })!}
/>
<Field
label="Endpoint"
labelClassName="!text-sm"
value={(config as ArizeConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder="https://otlp.arize.com"
/>
</>
)}
{type === TracingProvider.phoenix && (
<>
<Field
label="API Key"
labelClassName="!text-sm"
isRequired
value={(config as PhoenixConfig).api_key}
onChange={handleConfigChange('api_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: 'API Key' })!}
/>
<Field
label={t(`${I18N_PREFIX}.project`, { ns: 'app' })!}
labelClassName="!text-sm"
isRequired
value={(config as PhoenixConfig).project}
onChange={handleConfigChange('project')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: t(`${I18N_PREFIX}.project`, { ns: 'app' }) })!}
/>
<Field
label="Endpoint"
labelClassName="!text-sm"
value={(config as PhoenixConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder="https://app.phoenix.arize.com"
/>
</>
)}
{type === TracingProvider.aliyun && (
<>
<Field
label="License Key"
labelClassName="!text-sm"
isRequired
value={(config as AliyunConfig).license_key}
onChange={handleConfigChange('license_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: 'License Key' })!}
/>
<Field
label="Endpoint"
labelClassName="!text-sm"
value={(config as AliyunConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder="https://tracing.arms.aliyuncs.com"
/>
<Field
label="App Name"
labelClassName="!text-sm"
value={(config as AliyunConfig).app_name}
onChange={handleConfigChange('app_name')}
/>
</>
)}
{type === TracingProvider.tencent && (
<>
<Field
label="Token"
labelClassName="!text-sm"
isRequired
value={(config as TencentConfig).token}
onChange={handleConfigChange('token')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: 'Token' })!}
/>
<Field
label="Endpoint"
labelClassName="!text-sm"
isRequired
value={(config as TencentConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder="https://your-region.cls.tencentcs.com"
/>
<Field
label="Service Name"
labelClassName="!text-sm"
isRequired
value={(config as TencentConfig).service_name}
onChange={handleConfigChange('service_name')}
placeholder="dify_app"
/>
</>
)}
{type === TracingProvider.weave && (
<>
<Field
label="API Key"
labelClassName="!text-sm"
isRequired
value={(config as WeaveConfig).api_key}
onChange={handleConfigChange('api_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: 'API Key' })!}
/>
<Field
label={t(`${I18N_PREFIX}.project`, { ns: 'app' })!}
labelClassName="!text-sm"
isRequired
value={(config as WeaveConfig).project}
onChange={handleConfigChange('project')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: t(`${I18N_PREFIX}.project`, { ns: 'app' }) })!}
/>
<Field
label="Entity"
labelClassName="!text-sm"
value={(config as WeaveConfig).entity}
onChange={handleConfigChange('entity')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: 'Entity' })!}
/>
<Field
label="Endpoint"
labelClassName="!text-sm"
value={(config as WeaveConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder="https://trace.wandb.ai/"
/>
<Field
label="Host"
labelClassName="!text-sm"
value={(config as WeaveConfig).host}
onChange={handleConfigChange('host')}
placeholder="https://api.wandb.ai"
/>
</>
)}
{type === TracingProvider.langSmith && (
<>
<Field
label="API Key"
labelClassName="!text-sm"
isRequired
value={(config as LangSmithConfig).api_key}
onChange={handleConfigChange('api_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: 'API Key' })!}
/>
<Field
label={t(`${I18N_PREFIX}.project`, { ns: 'app' })!}
labelClassName="!text-sm"
isRequired
value={(config as LangSmithConfig).project}
onChange={handleConfigChange('project')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: t(`${I18N_PREFIX}.project`, { ns: 'app' }) })!}
/>
<Field
label="Endpoint"
labelClassName="!text-sm"
value={(config as LangSmithConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder="https://api.smith.langchain.com"
/>
</>
)}
{type === TracingProvider.langfuse && (
<>
<Field
label={t(`${I18N_PREFIX}.secretKey`, { ns: 'app' })!}
labelClassName="!text-sm"
value={(config as LangFuseConfig).secret_key}
isRequired
onChange={handleConfigChange('secret_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: t(`${I18N_PREFIX}.secretKey`, { ns: 'app' }) })!}
/>
<Field
label={t(`${I18N_PREFIX}.publicKey`, { ns: 'app' })!}
labelClassName="!text-sm"
isRequired
value={(config as LangFuseConfig).public_key}
onChange={handleConfigChange('public_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: t(`${I18N_PREFIX}.publicKey`, { ns: 'app' }) })!}
/>
<Field
label="Host"
labelClassName="!text-sm"
isRequired
value={(config as LangFuseConfig).host}
onChange={handleConfigChange('host')}
placeholder="https://cloud.langfuse.com"
/>
</>
)}
{type === TracingProvider.opik && (
<>
<Field
label="API Key"
labelClassName="!text-sm"
value={(config as OpikConfig).api_key}
onChange={handleConfigChange('api_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: 'API Key' })!}
/>
<Field
label={t(`${I18N_PREFIX}.project`, { ns: 'app' })!}
labelClassName="!text-sm"
value={(config as OpikConfig).project}
onChange={handleConfigChange('project')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: t(`${I18N_PREFIX}.project`, { ns: 'app' }) })!}
/>
<Field
label="Workspace"
labelClassName="!text-sm"
value={(config as OpikConfig).workspace}
onChange={handleConfigChange('workspace')}
placeholder="default"
/>
<Field
label="Url"
labelClassName="!text-sm"
value={(config as OpikConfig).url}
onChange={handleConfigChange('url')}
placeholder="https://www.comet.com/opik/api/"
/>
</>
)}
{type === TracingProvider.mlflow && (
<>
<Field
label={t(`${I18N_PREFIX}.trackingUri`, { ns: 'app' })!}
labelClassName="!text-sm"
value={(config as MLflowConfig).tracking_uri}
isRequired
onChange={handleConfigChange('tracking_uri')}
placeholder="http://localhost:5000"
/>
<Field
label={t(`${I18N_PREFIX}.experimentId`, { ns: 'app' })!}
labelClassName="!text-sm"
isRequired
value={(config as MLflowConfig).experiment_id}
onChange={handleConfigChange('experiment_id')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: t(`${I18N_PREFIX}.experimentId`, { ns: 'app' }) })!}
/>
<Field
label={t(`${I18N_PREFIX}.username`, { ns: 'app' })!}
labelClassName="!text-sm"
value={(config as MLflowConfig).username}
onChange={handleConfigChange('username')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: t(`${I18N_PREFIX}.username`, { ns: 'app' }) })!}
/>
<Field
label={t(`${I18N_PREFIX}.password`, { ns: 'app' })!}
labelClassName="!text-sm"
value={(config as MLflowConfig).password}
onChange={handleConfigChange('password')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: t(`${I18N_PREFIX}.password`, { ns: 'app' }) })!}
/>
</>
)}
{type === TracingProvider.databricks && (
<>
<Field
label={t(`${I18N_PREFIX}.experimentId`, { ns: 'app' })!}
labelClassName="!text-sm"
value={(config as DatabricksConfig).experiment_id}
onChange={handleConfigChange('experiment_id')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: t(`${I18N_PREFIX}.experimentId`, { ns: 'app' }) })!}
isRequired
/>
<Field
label={t(`${I18N_PREFIX}.databricksHost`, { ns: 'app' })!}
labelClassName="!text-sm"
value={(config as DatabricksConfig).host}
onChange={handleConfigChange('host')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: t(`${I18N_PREFIX}.databricksHost`, { ns: 'app' }) })!}
isRequired
/>
<Field
label={t(`${I18N_PREFIX}.clientId`, { ns: 'app' })!}
labelClassName="!text-sm"
value={(config as DatabricksConfig).client_id}
onChange={handleConfigChange('client_id')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: t(`${I18N_PREFIX}.clientId`, { ns: 'app' }) })!}
/>
<Field
label={t(`${I18N_PREFIX}.clientSecret`, { ns: 'app' })!}
labelClassName="!text-sm"
value={(config as DatabricksConfig).client_secret}
onChange={handleConfigChange('client_secret')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: t(`${I18N_PREFIX}.clientSecret`, { ns: 'app' }) })!}
/>
<Field
label={t(`${I18N_PREFIX}.personalAccessToken`, { ns: 'app' })!}
labelClassName="!text-sm"
value={(config as DatabricksConfig).personal_access_token}
onChange={handleConfigChange('personal_access_token')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { ns: 'app', key: t(`${I18N_PREFIX}.personalAccessToken`, { ns: 'app' }) })!}
/>
</>
)}
</div>
<div className="my-8 flex h-8 items-center justify-between">
<a
className="flex items-center space-x-1 text-xs font-normal leading-[18px] text-[#155EEF]"
target="_blank"
href={docURL[type]}
>
<span>{t(`${I18N_PREFIX}.viewDocsLink`, { ns: 'app', key: t(`tracing.${type}.title`, { ns: 'app' }) })}</span>
<LinkExternal02 className="h-3 w-3" />
</a>
<div className="flex items-center">
{isEdit && (
<>
<Button
className="h-9 text-sm font-medium text-text-secondary"
onClick={showRemoveConfirm}
>
<span className="text-[#D92D20]">{t('operation.remove', { ns: 'common' })}</span>
</Button>
<Divider type="vertical" className="mx-3 h-[18px]" />
</>
)}
<Button
className="mr-2 h-9 text-sm font-medium text-text-secondary"
onClick={onCancel}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
className="h-9 text-sm font-medium"
variant="primary"
onClick={handleSave}
loading={isSaving}
>
{t(`operation.${isAdd ? 'saveAndEnable' : 'save'}`, { ns: 'common' })}
</Button>
</div>
</div>
</div>
</div>
<div className='border-t-[0.5px] border-divider-regular'>
<div className='flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary'>
<Lock01 className='mr-1 h-3 w-3 text-text-tertiary' />
{t('common.modelProvider.encrypted.front')}
<a
className='mx-1 text-primary-600'
target='_blank' rel='noopener noreferrer'
href='https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html'
>
PKCS1_OAEP
</a>
{t('common.modelProvider.encrypted.back')}
<div className="border-t-[0.5px] border-divider-regular">
<div className="flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary">
<Lock01 className="mr-1 h-3 w-3 text-text-tertiary" />
{t('modelProvider.encrypted.front', { ns: 'common' })}
<a
className="mx-1 text-primary-600"
target="_blank"
rel="noopener noreferrer"
href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html"
>
PKCS1_OAEP
</a>
{t('modelProvider.encrypted.back', { ns: 'common' })}
</div>
</div>
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
</PortalToFollowElemContent>
</PortalToFollowElem>
)
: (
<Confirm
isShow
type='warning'
title={t(`${I18N_PREFIX}.removeConfirmTitle`, { key: t(`app.tracing.${type}.title`) })!}
content={t(`${I18N_PREFIX}.removeConfirmContent`)}
onConfirm={handleRemove}
onCancel={hideRemoveConfirm}
/>
)}
<Confirm
isShow
type="warning"
title={t(`${I18N_PREFIX}.removeConfirmTitle`, { ns: 'app', key: t(`tracing.${type}.title`, { ns: 'app' }) })!}
content={t(`${I18N_PREFIX}.removeConfirmContent`, { ns: 'app' })}
onConfirm={handleRemove}
onCancel={hideRemoveConfirm}
/>
)}
</>
)
}

View File

@ -1,16 +1,17 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import {
RiEqualizer2Line,
} from '@remixicon/react'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { TracingProvider } from './type'
import cn from '@/utils/classnames'
import { AliyunIconBig, ArizeIconBig, DatabricksIconBig, LangfuseIconBig, LangsmithIconBig, MlflowIconBig, OpikIconBig, PhoenixIconBig, TencentIconBig, WeaveIconBig } from '@/app/components/base/icons/src/public/tracing'
import { Eye as View } from '@/app/components/base/icons/src/vender/solid/general'
import { cn } from '@/utils/classnames'
import { TracingProvider } from './type'
const I18N_PREFIX = 'app.tracing'
const I18N_PREFIX = 'tracing'
type Props = {
type: TracingProvider
@ -78,31 +79,31 @@ const ProviderPanel: FC<Props> = ({
)}
onClick={handleChosen}
>
<div className={'flex items-center justify-between space-x-1'}>
<div className='flex items-center'>
<Icon className='h-6' />
{isChosen && <div className='system-2xs-medium-uppercase ml-1 flex h-4 items-center rounded-[4px] border border-text-accent-secondary px-1 text-text-accent-secondary'>{t(`${I18N_PREFIX}.inUse`)}</div>}
<div className="flex items-center justify-between space-x-1">
<div className="flex items-center">
<Icon className="h-6" />
{isChosen && <div className="system-2xs-medium-uppercase ml-1 flex h-4 items-center rounded-[4px] border border-text-accent-secondary px-1 text-text-accent-secondary">{t(`${I18N_PREFIX}.inUse`, { ns: 'app' })}</div>}
</div>
{!readOnly && (
<div className={'flex items-center justify-between space-x-1'}>
<div className="flex items-center justify-between space-x-1">
{hasConfigured && (
<div className='flex h-6 cursor-pointer items-center space-x-1 rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-text-secondary shadow-xs' onClick={viewBtnClick} >
<View className='h-3 w-3' />
<div className='text-xs font-medium'>{t(`${I18N_PREFIX}.view`)}</div>
<div className="flex h-6 cursor-pointer items-center space-x-1 rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-text-secondary shadow-xs" onClick={viewBtnClick}>
<View className="h-3 w-3" />
<div className="text-xs font-medium">{t(`${I18N_PREFIX}.view`, { ns: 'app' })}</div>
</div>
)}
<div
className='flex h-6 cursor-pointer items-center space-x-1 rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-text-secondary shadow-xs'
className="flex h-6 cursor-pointer items-center space-x-1 rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-text-secondary shadow-xs"
onClick={handleConfigBtnClick}
>
<RiEqualizer2Line className='h-3 w-3' />
<div className='text-xs font-medium'>{t(`${I18N_PREFIX}.config`)}</div>
<RiEqualizer2Line className="h-3 w-3" />
<div className="text-xs font-medium">{t(`${I18N_PREFIX}.config`, { ns: 'app' })}</div>
</div>
</div>
)}
</div>
<div className='system-xs-regular mt-2 text-text-tertiary'>
{t(`${I18N_PREFIX}.${type}.description`)}
<div className="system-xs-regular mt-2 text-text-tertiary">
{t(`${I18N_PREFIX}.${type}.description`, { ns: 'app' })}
</div>
</div>
)

View File

@ -1,8 +1,8 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import * as React from 'react'
import { TracingIcon as Icon } from '@/app/components/base/icons/src/public/tracing'
import { cn } from '@/utils/classnames'
type Props = {
className?: string
@ -21,7 +21,7 @@ const TracingIcon: FC<Props> = ({
const sizeClass = sizeClassMap[size]
return (
<div className={cn(className, sizeClass, 'bg-primary-500 shadow-md')}>
<Icon className='h-full w-full' />
<Icon className="h-full w-full" />
</div>
)
}

View File

@ -2,7 +2,7 @@ import WorkflowApp from '@/app/components/workflow-app'
const Page = () => {
return (
<div className='h-full w-full overflow-x-auto'>
<div className="h-full w-full overflow-x-auto">
<WorkflowApp />
</div>
)

View File

@ -1,7 +1,8 @@
'use client'
import type { FC } from 'react'
import React, { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import useDocumentTitle from '@/hooks/use-document-title'
@ -14,7 +15,7 @@ const AppDetail: FC<IAppDetail> = ({ children }) => {
const router = useRouter()
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const { t } = useTranslation()
useDocumentTitle(t('common.menus.appDetail'))
useDocumentTitle(t('menus.appDetail', { ns: 'common' }))
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)

View File

@ -1,4 +1,4 @@
import React from 'react'
import * as React from 'react'
const page = () => {
return (

View File

@ -1,8 +1,8 @@
import React from 'react'
import * as React from 'react'
import MainDetail from '@/app/components/datasets/documents/detail'
export type IDocumentDetailProps = {
params: Promise<{ datasetId: string; documentId: string }>
params: Promise<{ datasetId: string, documentId: string }>
}
const DocumentDetail = async (props: IDocumentDetailProps) => {

View File

@ -1,8 +1,8 @@
import React from 'react'
import * as React from 'react'
import Settings from '@/app/components/datasets/documents/detail/settings'
export type IProps = {
params: Promise<{ datasetId: string; documentId: string }>
params: Promise<{ datasetId: string, documentId: string }>
}
const DocumentSettings = async (props: IProps) => {

View File

@ -1,4 +1,4 @@
import React from 'react'
import * as React from 'react'
import CreateFromPipeline from '@/app/components/datasets/documents/create-from-pipeline'
const CreateFromPipelinePage = async () => {

View File

@ -1,4 +1,4 @@
import React from 'react'
import * as React from 'react'
import DatasetUpdateForm from '@/app/components/datasets/create'
export type IProps = {

View File

@ -1,4 +1,4 @@
import React from 'react'
import * as React from 'react'
import Main from '@/app/components/datasets/documents'
export type IProps = {

View File

@ -1,4 +1,4 @@
import React from 'react'
import * as React from 'react'
import Main from '@/app/components/datasets/hit-testing'
type Props = {

View File

@ -1,9 +1,6 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { usePathname } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import type { RemixiconComponentType } from '@remixicon/react'
import type { FC } from 'react'
import {
RiEqualizer2Fill,
RiEqualizer2Line,
@ -12,18 +9,22 @@ import {
RiFocus2Fill,
RiFocus2Line,
} from '@remixicon/react'
import { usePathname } from 'next/navigation'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppSideBar from '@/app/components/app-sidebar'
import Loading from '@/app/components/base/loading'
import DatasetDetailContext from '@/context/dataset-detail'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
import { PipelineFill, PipelineLine } from '@/app/components/base/icons/src/vender/pipeline'
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import useDocumentTitle from '@/hooks/use-document-title'
import Loading from '@/app/components/base/loading'
import ExtraInfo from '@/app/components/datasets/extra-info'
import { useAppContext } from '@/context/app-context'
import DatasetDetailContext from '@/context/dataset-detail'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import cn from '@/utils/classnames'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import { cn } from '@/utils/classnames'
export type IAppDetailLayoutProps = {
children: React.ReactNode
@ -69,14 +70,14 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const navigation = useMemo(() => {
const baseNavigation = [
{
name: t('common.datasetMenus.hitTesting'),
name: t('datasetMenus.hitTesting', { ns: 'common' }),
href: `/datasets/${datasetId}/hitTesting`,
icon: RiFocus2Line,
selectedIcon: RiFocus2Fill,
disabled: isButtonDisabledWithPipeline,
},
{
name: t('common.datasetMenus.settings'),
name: t('datasetMenus.settings', { ns: 'common' }),
href: `/datasets/${datasetId}/settings`,
icon: RiEqualizer2Line,
selectedIcon: RiEqualizer2Fill,
@ -86,14 +87,14 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
if (datasetRes?.provider !== 'external') {
baseNavigation.unshift({
name: t('common.datasetMenus.pipeline'),
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
})
baseNavigation.unshift({
name: t('common.datasetMenus.documents'),
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
@ -104,7 +105,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
return baseNavigation
}, [t, datasetId, isButtonDisabledWithPipeline, datasetRes?.provider])
useDocumentTitle(datasetRes?.name || t('common.menus.datasets'))
useDocumentTitle(datasetRes?.name || t('menus.datasets', { ns: 'common' }))
const setAppSidebarExpand = useStore(state => state.setAppSidebarExpand)
@ -115,7 +116,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}, [isMobile, setAppSidebarExpand])
if (!datasetRes && !error)
return <Loading type='app' />
return <Loading type="app" />
return (
<div
@ -128,7 +129,8 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
indexingTechnique: datasetRes?.indexing_technique,
dataset: datasetRes,
mutateDatasetRes,
}}>
}}
>
{!hideSideBar && (
<AppSideBar
navigation={navigation}
@ -137,10 +139,10 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
? mode => <ExtraInfo relatedApps={relatedApps} expand={mode === 'expand'} documentCount={datasetRes?.document_count} />
: undefined
}
iconType='dataset'
iconType="dataset"
/>
)}
<div className='grow overflow-hidden bg-background-default-subtle'>{children}</div>
<div className="grow overflow-hidden bg-background-default-subtle">{children}</div>
</DatasetDetailContext.Provider>
</div>
)

View File

@ -3,7 +3,7 @@ import RagPipeline from '@/app/components/rag-pipeline'
const PipelinePage = () => {
return (
<div className='h-full w-full overflow-x-auto'>
<div className="h-full w-full overflow-x-auto">
<RagPipeline />
</div>
)

View File

@ -1,16 +1,17 @@
import React from 'react'
import { getLocaleOnServer, useTranslation as translate } from '@/i18n-config/server'
/* eslint-disable dify-i18n/require-ns-option */
import * as React from 'react'
import Form from '@/app/components/datasets/settings/form'
import { getLocaleOnServer, getTranslation } from '@/i18n-config/server'
const Settings = async () => {
const locale = await getLocaleOnServer()
const { t } = await translate(locale, 'dataset-settings')
const { t } = await getTranslation(locale, 'dataset-settings')
return (
<div className='h-full overflow-y-auto'>
<div className='flex flex-col gap-y-0.5 px-6 pb-2 pt-3'>
<div className='system-xl-semibold text-text-primary'>{t('title')}</div>
<div className='system-sm-regular text-text-tertiary'>{t('desc')}</div>
<div className="h-full overflow-y-auto">
<div className="flex flex-col gap-y-0.5 px-6 pb-2 pt-3">
<div className="system-xl-semibold text-text-primary">{t('title')}</div>
<div className="system-sm-regular text-text-tertiary">{t('desc')}</div>
</div>
<Form />
</div>

View File

@ -1,5 +1,5 @@
import type { FC } from 'react'
import React from 'react'
import * as React from 'react'
export type IDatasetDetail = {
children: React.ReactNode

View File

@ -1,4 +1,4 @@
import React from 'react'
import * as React from 'react'
import ExternalKnowledgeBaseConnector from '@/app/components/datasets/external-knowledge-base/connector'
const ExternalKnowledgeBaseCreation = () => {

View File

@ -1,4 +1,4 @@
import React from 'react'
import * as React from 'react'
import CreateFromPipeline from '@/app/components/datasets/create-from-pipeline'
const DatasetCreation = async () => {

View File

@ -1,4 +1,4 @@
import React from 'react'
import * as React from 'react'
import DatasetUpdateForm from '@/app/components/datasets/create'
const DatasetCreation = async () => {

View File

@ -1,11 +1,11 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { ExternalApiPanelProvider } from '@/context/external-api-panel-context'
import { ExternalKnowledgeApiProvider } from '@/context/external-knowledge-api-context'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
export default function DatasetsLayout({ children }: { children: React.ReactNode }) {
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, currentWorkspace, isLoadingCurrentWorkspace } = useAppContext()
@ -19,7 +19,7 @@ export default function DatasetsLayout({ children }: { children: React.ReactNode
}, [isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace, currentWorkspace, router])
if (isLoadingCurrentWorkspace || !(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator))
return <Loading type='app' />
return <Loading type="app" />
return (
<ExternalKnowledgeApiProvider>
<ExternalApiPanelProvider>

View File

@ -1,13 +1,13 @@
'use client'
import {
useEffect,
useMemo,
} from 'react'
import {
useRouter,
useSearchParams,
} from 'next/navigation'
import {
useEffect,
useMemo,
} from 'react'
import EducationApplyPage from '@/app/education-apply/education-apply-page'
import { useProviderContext } from '@/context/provider-context'

View File

@ -1,4 +1,4 @@
import React from 'react'
import * as React from 'react'
import AppList from '@/app/components/explore/app-list'
const Apps = () => {

View File

@ -1,4 +1,4 @@
import React from 'react'
import * as React from 'react'
import Main from '@/app/components/explore/installed-app'
export type IInstalledAppProps = {

View File

@ -1,13 +1,13 @@
'use client'
import type { FC, PropsWithChildren } from 'react'
import React from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import ExploreClient from '@/app/components/explore'
import useDocumentTitle from '@/hooks/use-document-title'
const ExploreLayout: FC<PropsWithChildren> = ({ children }) => {
const { t } = useTranslation()
useDocumentTitle(t('common.menus.explore'))
useDocumentTitle(t('menus.explore', { ns: 'common' }))
return (
<ExploreClient>
{children}

View File

@ -1,18 +1,18 @@
import React from 'react'
import type { ReactNode } from 'react'
import SwrInitializer from '@/app/components/swr-initializer'
import { AppContextProvider } from '@/context/app-context'
import GA, { GaType } from '@/app/components/base/ga'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import AmplitudeProvider from '@/app/components/base/amplitude'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import Header from '@/app/components/header'
import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ProviderContextProvider } from '@/context/provider-context'
import { ModalContextProvider } from '@/context/modal-context'
import GotoAnything from '@/app/components/goto-anything'
import GA, { GaType } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import PartnerStack from '../components/billing/partner-stack'
import GotoAnything from '@/app/components/goto-anything'
import Header from '@/app/components/header'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import { AppContextProvider } from '@/context/app-context'
import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ModalContextProvider } from '@/context/modal-context'
import { ProviderContextProvider } from '@/context/provider-context'
import PartnerStack from '../components/billing/partner-stack'
import Splash from '../components/splash'
const Layout = ({ children }: { children: ReactNode }) => {
@ -20,7 +20,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
<>
<GA gaType={GaType.admin} />
<AmplitudeProvider />
<SwrInitializer>
<AppInitializer>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
@ -38,7 +38,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
</EventEmitterContextProvider>
</AppContextProvider>
<Zendesk />
</SwrInitializer>
</AppInitializer>
</>
)
}

View File

@ -1,6 +1,6 @@
import Marketplace from '@/app/components/plugins/marketplace'
import PluginPage from '@/app/components/plugins/plugin-page'
import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel'
import Marketplace from '@/app/components/plugins/marketplace'
import { getLocaleOnServer } from '@/i18n-config/server'
const PluginList = async () => {
@ -8,7 +8,7 @@ const PluginList = async () => {
return (
<PluginPage
plugins={<PluginsPanel />}
marketplace={<Marketplace locale={locale} pluginTypeSwitchClassName='top-[60px]' showSearchParams={false} />}
marketplace={<Marketplace locale={locale} pluginTypeSwitchClassName="top-[60px]" showSearchParams={false} />}
/>
)
}

View File

@ -1,16 +1,18 @@
'use client'
import type { FC } from 'react'
import { useRouter } from 'next/navigation'
import React, { useEffect } from 'react'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import ToolProviderList from '@/app/components/tools/provider-list'
import { useAppContext } from '@/context/app-context'
import useDocumentTitle from '@/hooks/use-document-title'
const ToolsList: FC = () => {
const router = useRouter()
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const { t } = useTranslation()
useDocumentTitle(t('common.menus.tools'))
useDocumentTitle(t('menus.tools', { ns: 'common' }))
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)

View File

@ -1,5 +1,5 @@
'use client'
import React from 'react'
import * as React from 'react'
import ChatWithHistoryWrap from '@/app/components/base/chat/chat-with-history'
import AuthenticatedLayout from '../../components/authenticated-layout'

View File

@ -1,5 +1,5 @@
'use client'
import React from 'react'
import * as React from 'react'
import EmbeddedChatbot from '@/app/components/base/chat/embedded-chatbot'
import AuthenticatedLayout from '../../components/authenticated-layout'

View File

@ -1,4 +1,4 @@
import React from 'react'
import * as React from 'react'
import Main from '@/app/components/share/text-generation'
import AuthenticatedLayout from '../../components/authenticated-layout'

View File

@ -1,14 +1,15 @@
'use client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import { useWebAppStore } from '@/context/web-app-context'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetWebAppInfo, useGetWebAppMeta, useGetWebAppParams } from '@/service/use-share'
import { webAppLogout } from '@/service/webapp-auth'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import React, { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation()
@ -49,35 +50,47 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
}, [getSigninUrl, router, webAppLogout, shareCode])
if (appInfoError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={appInfoError.message} />
</div>
return (
<div className="flex h-full items-center justify-center">
<AppUnavailable unknownReason={appInfoError.message} />
</div>
)
}
if (appParamsError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={appParamsError.message} />
</div>
return (
<div className="flex h-full items-center justify-center">
<AppUnavailable unknownReason={appParamsError.message} />
</div>
)
}
if (appMetaError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={appMetaError.message} />
</div>
return (
<div className="flex h-full items-center justify-center">
<AppUnavailable unknownReason={appMetaError.message} />
</div>
)
}
if (useCanAccessAppError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={useCanAccessAppError.message} />
</div>
return (
<div className="flex h-full items-center justify-center">
<AppUnavailable unknownReason={useCanAccessAppError.message} />
</div>
)
}
if (userCanAccessApp && !userCanAccessApp.result) {
return <div className='flex h-full flex-col items-center justify-center gap-y-2'>
<AppUnavailable className='h-auto w-auto' code={403} unknownReason='no permission.' />
<span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('common.userProfile.logout')}</span>
</div>
return (
<div className="flex h-full flex-col items-center justify-center gap-y-2">
<AppUnavailable className="h-auto w-auto" code={403} unknownReason="no permission." />
<span className="system-sm-regular cursor-pointer text-text-tertiary" onClick={backToHome}>{t('userProfile.logout', { ns: 'common' })}</span>
</div>
)
}
if (isFetchingAppInfo || isFetchingAppParams || isFetchingAppMeta) {
return <div className='flex h-full items-center justify-center'>
<Loading />
</div>
return (
<div className="flex h-full items-center justify-center">
<Loading />
</div>
)
}
return <>{children}</>
}

View File

@ -1,15 +1,13 @@
'use client'
import type { FC, PropsWithChildren } from 'react'
import { useEffect, useState } from 'react'
import { useCallback } from 'react'
import { useWebAppStore } from '@/context/web-app-context'
import { useRouter, useSearchParams } from 'next/navigation'
import AppUnavailable from '@/app/components/base/app-unavailable'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { webAppLoginStatus, webAppLogout } from '@/service/webapp-auth'
import { fetchAccessToken } from '@/service/share'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth'
import { useWebAppStore } from '@/context/web-app-context'
import { fetchAccessToken } from '@/service/share'
import { setWebAppAccessToken, setWebAppPassport, webAppLoginStatus, webAppLogout } from '@/service/webapp-auth'
const Splash: FC<PropsWithChildren> = ({ children }) => {
const { t } = useTranslation()
@ -42,7 +40,7 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
return
}
if(tokenFromUrl)
if (tokenFromUrl)
setWebAppAccessToken(tokenFromUrl)
const redirectOrFinish = () => {
@ -90,19 +88,24 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
message,
webAppAccessMode,
tokenFromUrl,
embeddedUserId])
embeddedUserId,
])
if (message) {
return <div className='flex h-full flex-col items-center justify-center gap-y-4'>
<AppUnavailable className='h-auto w-auto' code={code || t('share.common.appUnavailable')} unknownReason={message} />
<span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{code === '403' ? t('common.userProfile.logout') : t('share.login.backToHome')}</span>
</div>
return (
<div className="flex h-full flex-col items-center justify-center gap-y-4">
<AppUnavailable className="h-auto w-auto" code={code || t('common.appUnavailable', { ns: 'share' })} unknownReason={message} />
<span className="system-sm-regular cursor-pointer text-text-tertiary" onClick={backToHome}>{code === '403' ? t('userProfile.logout', { ns: 'common' }) : t('login.backToHome', { ns: 'share' })}</span>
</div>
)
}
if (isLoading) {
return <div className='flex h-full items-center justify-center'>
<Loading />
</div>
return (
<div className="flex h-full items-center justify-center">
<Loading />
</div>
)
}
return <>{children}</>
}

View File

@ -1,15 +1,15 @@
'use client'
import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Countdown from '@/app/components/signin/countdown'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common'
import Countdown from '@/app/components/signin/countdown'
import I18NContext from '@/context/i18n'
import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common'
export default function CheckCode() {
const { t } = useTranslation()
@ -26,14 +26,14 @@ export default function CheckCode() {
if (!code.trim()) {
Toast.notify({
type: 'error',
message: t('login.checkCode.emptyCode'),
message: t('checkCode.emptyCode', { ns: 'login' }),
})
return
}
if (!/\d{6}/.test(code)) {
Toast.notify({
type: 'error',
message: t('login.checkCode.invalidCode'),
message: t('checkCode.invalidCode', { ns: 'login' }),
})
return
}
@ -63,37 +63,39 @@ export default function CheckCode() {
catch (error) { console.error(error) }
}
return <div className='flex flex-col gap-3'>
<div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge text-text-accent-light-mode-only shadow-lg'>
<RiMailSendFill className='h-6 w-6 text-2xl' />
</div>
<div className='pb-4 pt-2'>
<h2 className='title-4xl-semi-bold text-text-primary'>{t('login.checkCode.checkYourEmail')}</h2>
<p className='body-md-regular mt-2 text-text-secondary'>
<span>
{t('login.checkCode.tipsPrefix')}
<strong>{email}</strong>
</span>
<br />
{t('login.checkCode.validTime')}
</p>
</div>
<form action="">
<input type='text' className='hidden' />
<label htmlFor="code" className='system-md-semibold mb-1 text-text-secondary'>{t('login.checkCode.verificationCode')}</label>
<Input value={code} onChange={e => setVerifyCode(e.target.value)} maxLength={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') || ''} />
<Button loading={loading} disabled={loading} className='my-3 w-full' variant='primary' onClick={verify}>{t('login.checkCode.verify')}</Button>
<Countdown onResend={resendCode} />
</form>
<div className='py-2'>
<div className='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
</div>
<div onClick={() => router.back()} className='flex h-9 cursor-pointer items-center justify-center text-text-tertiary'>
<div className='bg-background-default-dimm inline-block rounded-full p-1'>
<RiArrowLeftLine size={12} />
return (
<div className="flex flex-col gap-3">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge text-text-accent-light-mode-only shadow-lg">
<RiMailSendFill className="h-6 w-6 text-2xl" />
</div>
<div className="pb-4 pt-2">
<h2 className="title-4xl-semi-bold text-text-primary">{t('checkCode.checkYourEmail', { ns: 'login' })}</h2>
<p className="body-md-regular mt-2 text-text-secondary">
<span>
{t('checkCode.tipsPrefix', { ns: 'login' })}
<strong>{email}</strong>
</span>
<br />
{t('checkCode.validTime', { ns: 'login' })}
</p>
</div>
<form action="">
<input type="text" className="hidden" />
<label htmlFor="code" className="system-md-semibold mb-1 text-text-secondary">{t('checkCode.verificationCode', { ns: 'login' })}</label>
<Input value={code} onChange={e => setVerifyCode(e.target.value)} maxLength={6} className="mt-1" placeholder={t('checkCode.verificationCodePlaceholder', { ns: 'login' }) || ''} />
<Button loading={loading} disabled={loading} className="my-3 w-full" variant="primary" onClick={verify}>{t('checkCode.verify', { ns: 'login' })}</Button>
<Countdown onResend={resendCode} />
</form>
<div className="py-2">
<div className="h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent"></div>
</div>
<div onClick={() => router.back()} className="flex h-9 cursor-pointer items-center justify-center text-text-tertiary">
<div className="bg-background-default-dimm inline-block rounded-full p-1">
<RiArrowLeftLine size={12} />
</div>
<span className="system-xs-regular ml-2">{t('back', { ns: 'login' })}</span>
</div>
<span className='system-xs-regular ml-2'>{t('login.back')}</span>
</div>
</div>
)
}

View File

@ -1,30 +1,39 @@
'use client'
import Header from '@/app/signin/_header'
import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { cn } from '@/utils/classnames'
export default function SignInLayout({ children }: any) {
const { systemFeatures } = useGlobalPublicStore()
return <>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<Header />
<div className={
cn(
'flex w-full grow flex-col items-center justify-center',
'px-6',
'md:px-[108px]',
)
}>
<div className='flex w-[400px] flex-col'>
{children}
return (
<>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<Header />
<div className={
cn(
'flex w-full grow flex-col items-center justify-center',
'px-6',
'md:px-[108px]',
)
}
>
<div className="flex w-[400px] flex-col">
{children}
</div>
</div>
{!systemFeatures.branding.enabled && (
<div className="system-xs-regular px-8 py-6 text-text-tertiary">
©
{' '}
{new Date().getFullYear()}
{' '}
LangGenius, Inc. All rights reserved.
</div>
)}
</div>
{!systemFeatures.branding.enabled && <div className='system-xs-regular px-8 py-6 text-text-tertiary'>
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
</div>}
</div>
</div>
</>
</>
)
}

View File

@ -1,19 +1,19 @@
'use client'
import Link from 'next/link'
import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { noop } from 'es-toolkit/compat'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { sendResetPasswordCode } from '@/service/common'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import I18NContext from '@/context/i18n'
import { noop } from 'lodash-es'
import useDocumentTitle from '@/hooks/use-document-title'
import { sendResetPasswordCode } from '@/service/common'
export default function CheckCode() {
const { t } = useTranslation()
@ -27,14 +27,14 @@ export default function CheckCode() {
const handleGetEMailVerificationCode = async () => {
try {
if (!email) {
Toast.notify({ type: 'error', message: t('login.error.emailEmpty') })
Toast.notify({ type: 'error', message: t('error.emailEmpty', { ns: 'login' }) })
return
}
if (!emailRegex.test(email)) {
Toast.notify({
type: 'error',
message: t('login.error.emailInValid'),
message: t('error.emailInValid', { ns: 'login' }),
})
return
}
@ -50,7 +50,7 @@ export default function CheckCode() {
else if (res.code === 'account_not_found') {
Toast.notify({
type: 'error',
message: t('login.error.registrationNotAllowed'),
message: t('error.registrationNotAllowed', { ns: 'login' }),
})
}
else {
@ -68,37 +68,39 @@ export default function CheckCode() {
}
}
return <div className='flex flex-col gap-3'>
<div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg'>
<RiLockPasswordLine className='h-6 w-6 text-2xl text-text-accent-light-mode-only' />
</div>
<div className='pb-4 pt-2'>
<h2 className='title-4xl-semi-bold text-text-primary'>{t('login.resetPassword')}</h2>
<p className='body-md-regular mt-2 text-text-secondary'>
{t('login.resetPasswordDesc')}
</p>
</div>
return (
<div className="flex flex-col gap-3">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg">
<RiLockPasswordLine className="h-6 w-6 text-2xl text-text-accent-light-mode-only" />
</div>
<div className="pb-4 pt-2">
<h2 className="title-4xl-semi-bold text-text-primary">{t('resetPassword', { ns: 'login' })}</h2>
<p className="body-md-regular mt-2 text-text-secondary">
{t('resetPasswordDesc', { ns: 'login' })}
</p>
</div>
<form onSubmit={noop}>
<input type='text' className='hidden' />
<div className='mb-2'>
<label htmlFor="email" className='system-md-semibold my-2 text-text-secondary'>{t('login.email')}</label>
<div className='mt-1'>
<Input id='email' type="email" disabled={loading} value={email} placeholder={t('login.emailPlaceholder') as string} onChange={e => setEmail(e.target.value)} />
</div>
<div className='mt-3'>
<Button loading={loading} disabled={loading} variant='primary' className='w-full' onClick={handleGetEMailVerificationCode}>{t('login.sendVerificationCode')}</Button>
<form onSubmit={noop}>
<input type="text" className="hidden" />
<div className="mb-2">
<label htmlFor="email" className="system-md-semibold my-2 text-text-secondary">{t('email', { ns: 'login' })}</label>
<div className="mt-1">
<Input id="email" type="email" disabled={loading} value={email} placeholder={t('emailPlaceholder', { ns: 'login' }) as string} onChange={e => setEmail(e.target.value)} />
</div>
<div className="mt-3">
<Button loading={loading} disabled={loading} variant="primary" className="w-full" onClick={handleGetEMailVerificationCode}>{t('sendVerificationCode', { ns: 'login' })}</Button>
</div>
</div>
</form>
<div className="py-2">
<div className="h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent"></div>
</div>
</form>
<div className='py-2'>
<div className='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
<Link href={`/webapp-signin?${searchParams.toString()}`} className="flex h-9 items-center justify-center text-text-tertiary hover:text-text-primary">
<div className="inline-block rounded-full bg-background-default-dimmed p-1">
<RiArrowLeftLine size={12} />
</div>
<span className="system-xs-regular ml-2">{t('backToLogin', { ns: 'login' })}</span>
</Link>
</div>
<Link href={`/webapp-signin?${searchParams.toString()}`} className='flex h-9 items-center justify-center text-text-tertiary hover:text-text-primary'>
<div className='inline-block rounded-full bg-background-default-dimmed p-1'>
<RiArrowLeftLine size={12} />
</div>
<span className='system-xs-regular ml-2'>{t('login.backToLogin')}</span>
</Link>
</div>
)
}

View File

@ -1,15 +1,15 @@
'use client'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter, useSearchParams } from 'next/navigation'
import cn from 'classnames'
import { RiCheckboxCircleFill } from '@remixicon/react'
import { useCountDown } from 'ahooks'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { changeWebAppPasswordWithToken } from '@/service/common'
import Toast from '@/app/components/base/toast'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { validPassword } from '@/config'
import { changeWebAppPasswordWithToken } from '@/service/common'
import { cn } from '@/utils/classnames'
const ChangePasswordForm = () => {
const { t } = useTranslation()
@ -45,15 +45,15 @@ const ChangePasswordForm = () => {
const valid = useCallback(() => {
if (!password.trim()) {
showErrorMessage(t('login.error.passwordEmpty'))
showErrorMessage(t('error.passwordEmpty', { ns: 'login' }))
return false
}
if (!validPassword.test(password)) {
showErrorMessage(t('login.error.passwordInvalid'))
showErrorMessage(t('error.passwordInvalid', { ns: 'login' }))
return false
}
if (password !== confirmPassword) {
showErrorMessage(t('common.account.notEqual'))
showErrorMessage(t('account.notEqual', { ns: 'common' }))
return false
}
return true
@ -86,62 +86,64 @@ const ChangePasswordForm = () => {
'px-6',
'md:px-[108px]',
)
}>
}
>
{!showSuccess && (
<div className='flex flex-col md:w-[400px]'>
<div className="flex flex-col md:w-[400px]">
<div className="mx-auto w-full">
<h2 className="title-4xl-semi-bold text-text-primary">
{t('login.changePassword')}
{t('changePassword', { ns: 'login' })}
</h2>
<p className='body-md-regular mt-2 text-text-secondary'>
{t('login.changePasswordTip')}
<p className="body-md-regular mt-2 text-text-secondary">
{t('changePasswordTip', { ns: 'login' })}
</p>
</div>
<div className="mx-auto mt-6 w-full">
<div className="bg-white">
{/* Password */}
<div className='mb-5'>
<div className="mb-5">
<label htmlFor="password" className="system-md-semibold my-2 text-text-secondary">
{t('common.account.newPassword')}
{t('account.newPassword', { ns: 'common' })}
</label>
<div className='relative mt-1'>
<div className="relative mt-1">
<Input
id="password" type={showPassword ? 'text' : 'password'}
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
placeholder={t('login.passwordPlaceholder') || ''}
placeholder={t('passwordPlaceholder', { ns: 'login' }) || ''}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
variant="ghost"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? '👀' : '😝'}
</Button>
</div>
</div>
<div className='body-xs-regular mt-1 text-text-secondary'>{t('login.error.passwordInvalid')}</div>
<div className="body-xs-regular mt-1 text-text-secondary">{t('error.passwordInvalid', { ns: 'login' })}</div>
</div>
{/* Confirm Password */}
<div className='mb-5'>
<div className="mb-5">
<label htmlFor="confirmPassword" className="system-md-semibold my-2 text-text-secondary">
{t('common.account.confirmPassword')}
{t('account.confirmPassword', { ns: 'common' })}
</label>
<div className='relative mt-1'>
<div className="relative mt-1">
<Input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
placeholder={t('login.confirmPasswordPlaceholder') || ''}
placeholder={t('confirmPasswordPlaceholder', { ns: 'login' }) || ''}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
variant="ghost"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? '👀' : '😝'}
@ -151,11 +153,11 @@ const ChangePasswordForm = () => {
</div>
<div>
<Button
variant='primary'
className='w-full'
variant="primary"
className="w-full"
onClick={handleChangePassword}
>
{t('login.changePasswordBtn')}
{t('changePasswordBtn', { ns: 'login' })}
</Button>
</div>
</div>
@ -166,17 +168,28 @@ const ChangePasswordForm = () => {
<div className="flex flex-col md:w-[400px]">
<div className="mx-auto w-full">
<div className="mb-3 flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle font-bold shadow-lg">
<RiCheckboxCircleFill className='h-6 w-6 text-text-success' />
<RiCheckboxCircleFill className="h-6 w-6 text-text-success" />
</div>
<h2 className="title-4xl-semi-bold text-text-primary">
{t('login.passwordChangedTip')}
{t('passwordChangedTip', { ns: 'login' })}
</h2>
</div>
<div className="mx-auto mt-6 w-full">
<Button variant='primary' className='w-full' onClick={() => {
setLeftTime(undefined)
router.replace(getSignInUrl())
}}>{t('login.passwordChanged')} ({Math.round(countdown / 1000)}) </Button>
<Button
variant="primary"
className="w-full"
onClick={() => {
setLeftTime(undefined)
router.replace(getSignInUrl())
}}
>
{t('passwordChanged', { ns: 'login' })}
{' '}
(
{Math.round(countdown / 1000)}
)
{' '}
</Button>
</div>
</div>
)}

View File

@ -1,18 +1,19 @@
'use client'
import type { FormEvent } from 'react'
import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Countdown from '@/app/components/signin/countdown'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common'
import Countdown from '@/app/components/signin/countdown'
import I18NContext from '@/context/i18n'
import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth'
import { fetchAccessToken } from '@/service/share'
import { useWebAppStore } from '@/context/web-app-context'
import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common'
import { fetchAccessToken } from '@/service/share'
import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth'
export default function CheckCode() {
const { t } = useTranslation()
@ -44,21 +45,21 @@ export default function CheckCode() {
if (!code.trim()) {
Toast.notify({
type: 'error',
message: t('login.checkCode.emptyCode'),
message: t('checkCode.emptyCode', { ns: 'login' }),
})
return
}
if (!/\d{6}/.test(code)) {
Toast.notify({
type: 'error',
message: t('login.checkCode.invalidCode'),
message: t('checkCode.invalidCode', { ns: 'login' }),
})
return
}
if (!redirectUrl || !appCode) {
Toast.notify({
type: 'error',
message: t('login.error.redirectUrlMissing'),
message: t('error.redirectUrlMissing', { ns: 'login' }),
})
return
}
@ -101,44 +102,46 @@ export default function CheckCode() {
catch (error) { console.error(error) }
}
return <div className='flex w-[400px] flex-col gap-3'>
<div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg'>
<RiMailSendFill className='h-6 w-6 text-2xl text-text-accent-light-mode-only' />
</div>
<div className='pb-4 pt-2'>
<h2 className='title-4xl-semi-bold text-text-primary'>{t('login.checkCode.checkYourEmail')}</h2>
<p className='body-md-regular mt-2 text-text-secondary'>
<span>
{t('login.checkCode.tipsPrefix')}
<strong>{email}</strong>
</span>
<br />
{t('login.checkCode.validTime')}
</p>
</div>
<form onSubmit={handleSubmit}>
<label htmlFor="code" className='system-md-semibold mb-1 text-text-secondary'>{t('login.checkCode.verificationCode')}</label>
<Input
ref={codeInputRef}
id='code'
value={code}
onChange={e => setVerifyCode(e.target.value)}
maxLength={6}
className='mt-1'
placeholder={t('login.checkCode.verificationCodePlaceholder') || ''}
/>
<Button type='submit' loading={loading} disabled={loading} className='my-3 w-full' variant='primary'>{t('login.checkCode.verify')}</Button>
<Countdown onResend={resendCode} />
</form>
<div className='py-2'>
<div className='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
</div>
<div onClick={() => router.back()} className='flex h-9 cursor-pointer items-center justify-center text-text-tertiary'>
<div className='bg-background-default-dimm inline-block rounded-full p-1'>
<RiArrowLeftLine size={12} />
return (
<div className="flex w-[400px] flex-col gap-3">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg">
<RiMailSendFill className="h-6 w-6 text-2xl text-text-accent-light-mode-only" />
</div>
<div className="pb-4 pt-2">
<h2 className="title-4xl-semi-bold text-text-primary">{t('checkCode.checkYourEmail', { ns: 'login' })}</h2>
<p className="body-md-regular mt-2 text-text-secondary">
<span>
{t('checkCode.tipsPrefix', { ns: 'login' })}
<strong>{email}</strong>
</span>
<br />
{t('checkCode.validTime', { ns: 'login' })}
</p>
</div>
<form onSubmit={handleSubmit}>
<label htmlFor="code" className="system-md-semibold mb-1 text-text-secondary">{t('checkCode.verificationCode', { ns: 'login' })}</label>
<Input
ref={codeInputRef}
id="code"
value={code}
onChange={e => setVerifyCode(e.target.value)}
maxLength={6}
className="mt-1"
placeholder={t('checkCode.verificationCodePlaceholder', { ns: 'login' }) || ''}
/>
<Button type="submit" loading={loading} disabled={loading} className="my-3 w-full" variant="primary">{t('checkCode.verify', { ns: 'login' })}</Button>
<Countdown onResend={resendCode} />
</form>
<div className="py-2">
<div className="h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent"></div>
</div>
<div onClick={() => router.back()} className="flex h-9 cursor-pointer items-center justify-center text-text-tertiary">
<div className="bg-background-default-dimm inline-block rounded-full p-1">
<RiArrowLeftLine size={12} />
</div>
<span className="system-xs-regular ml-2">{t('back', { ns: 'login' })}</span>
</div>
<span className='system-xs-regular ml-2'>{t('login.back')}</span>
</div>
</div>
)
}

View File

@ -1,12 +1,13 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import React, { useCallback, useEffect } from 'react'
import Toast from '@/app/components/base/toast'
import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { SSOProtocol } from '@/types/feature'
import Loading from '@/app/components/base/loading'
import * as React from 'react'
import { useCallback, useEffect } from 'react'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share'
import { SSOProtocol } from '@/types/feature'
const ExternalMemberSSOAuth = () => {
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
@ -68,9 +69,11 @@ const ExternalMemberSSOAuth = () => {
}, [handleSSOLogin])
if (!systemFeatures.webapp_auth.sso_config.protocol) {
return <div className="flex h-full items-center justify-center">
<AppUnavailable code={403} unknownReason='sso protocol is invalid.' />
</div>
return (
<div className="flex h-full items-center justify-center">
<AppUnavailable code={403} unknownReason="sso protocol is invalid." />
</div>
)
}
return (

View File

@ -1,15 +1,15 @@
import { noop } from 'es-toolkit/compat'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter, useSearchParams } from 'next/navigation'
import { useContext } from 'use-context-selector'
import Input from '@/app/components/base/input'
import Button from '@/app/components/base/button'
import { emailRegex } from '@/config'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { sendWebAppEMailLoginCode } from '@/service/common'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import I18NContext from '@/context/i18n'
import { noop } from 'lodash-es'
import { sendWebAppEMailLoginCode } from '@/service/common'
export default function MailAndCodeAuth() {
const { t } = useTranslation()
@ -23,14 +23,14 @@ export default function MailAndCodeAuth() {
const handleGetEMailVerificationCode = async () => {
try {
if (!email) {
Toast.notify({ type: 'error', message: t('login.error.emailEmpty') })
Toast.notify({ type: 'error', message: t('error.emailEmpty', { ns: 'login' }) })
return
}
if (!emailRegex.test(email)) {
Toast.notify({
type: 'error',
message: t('login.error.emailInValid'),
message: t('error.emailInValid', { ns: 'login' }),
})
return
}
@ -52,17 +52,18 @@ export default function MailAndCodeAuth() {
}
}
return (<form onSubmit={noop}>
<input type='text' className='hidden' />
<div className='mb-2'>
<label htmlFor="email" className='system-md-semibold my-2 text-text-secondary'>{t('login.email')}</label>
<div className='mt-1'>
<Input id='email' type="email" value={email} placeholder={t('login.emailPlaceholder') as string} onChange={e => setEmail(e.target.value)} />
return (
<form onSubmit={noop}>
<input type="text" className="hidden" />
<div className="mb-2">
<label htmlFor="email" className="system-md-semibold my-2 text-text-secondary">{t('email', { ns: 'login' })}</label>
<div className="mt-1">
<Input id="email" type="email" value={email} placeholder={t('emailPlaceholder', { ns: 'login' }) as string} onChange={e => setEmail(e.target.value)} />
</div>
<div className="mt-3">
<Button loading={loading} disabled={loading || !email} variant="primary" className="w-full" onClick={handleGetEMailVerificationCode}>{t('signup.verifyMail', { ns: 'login' })}</Button>
</div>
</div>
<div className='mt-3'>
<Button loading={loading} disabled={loading || !email} variant='primary' className='w-full' onClick={handleGetEMailVerificationCode}>{t('login.signup.verifyMail')}</Button>
</div>
</div>
</form>
</form>
)
}

View File

@ -1,17 +1,17 @@
'use client'
import { noop } from 'es-toolkit/compat'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter, useSearchParams } from 'next/navigation'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { emailRegex } from '@/config'
import { webAppLogin } from '@/service/common'
import Input from '@/app/components/base/input'
import I18NContext from '@/context/i18n'
import { useWebAppStore } from '@/context/web-app-context'
import { noop } from 'lodash-es'
import { webAppLogin } from '@/service/common'
import { fetchAccessToken } from '@/service/share'
import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth'
@ -46,25 +46,25 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
const appCode = getAppCodeFromRedirectUrl()
const handleEmailPasswordLogin = async () => {
if (!email) {
Toast.notify({ type: 'error', message: t('login.error.emailEmpty') })
Toast.notify({ type: 'error', message: t('error.emailEmpty', { ns: 'login' }) })
return
}
if (!emailRegex.test(email)) {
Toast.notify({
type: 'error',
message: t('login.error.emailInValid'),
message: t('error.emailInValid', { ns: 'login' }),
})
return
}
if (!password?.trim()) {
Toast.notify({ type: 'error', message: t('login.error.passwordEmpty') })
Toast.notify({ type: 'error', message: t('error.passwordEmpty', { ns: 'login' }) })
return
}
if (!redirectUrl || !appCode) {
Toast.notify({
type: 'error',
message: t('login.error.redirectUrlMissing'),
message: t('error.redirectUrlMissing', { ns: 'login' }),
})
return
}
@ -107,70 +107,74 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
}
}
return <form onSubmit={noop}>
<div className='mb-3'>
<label htmlFor="email" className="system-md-semibold my-2 text-text-secondary">
{t('login.email')}
</label>
<div className="mt-1">
<Input
value={email}
onChange={e => setEmail(e.target.value)}
id="email"
type="email"
autoComplete="email"
placeholder={t('login.emailPlaceholder') || ''}
tabIndex={1}
/>
</div>
</div>
<div className='mb-3'>
<label htmlFor="password" className="my-2 flex items-center justify-between">
<span className='system-md-semibold text-text-secondary'>{t('login.password')}</span>
<Link
href={`/webapp-reset-password?${searchParams.toString()}`}
className={`system-xs-regular ${isEmailSetup ? 'text-components-button-secondary-accent-text' : 'pointer-events-none text-components-button-secondary-accent-text-disabled'}`}
tabIndex={isEmailSetup ? 0 : -1}
aria-disabled={!isEmailSetup}
>
{t('login.forget')}
</Link>
</label>
<div className="relative mt-1">
<Input
value={password}
onChange={e => setPassword(e.target.value)}
id="password"
onKeyDown={(e) => {
if (e.key === 'Enter')
handleEmailPasswordLogin()
}}
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
placeholder={t('login.passwordPlaceholder') || ''}
tabIndex={2}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? '👀' : '😝'}
</Button>
return (
<form onSubmit={noop}>
<div className="mb-3">
<label htmlFor="email" className="system-md-semibold my-2 text-text-secondary">
{t('email', { ns: 'login' })}
</label>
<div className="mt-1">
<Input
value={email}
onChange={e => setEmail(e.target.value)}
id="email"
type="email"
autoComplete="email"
placeholder={t('emailPlaceholder', { ns: 'login' }) || ''}
tabIndex={1}
/>
</div>
</div>
</div>
<div className='mb-2'>
<Button
tabIndex={2}
variant='primary'
onClick={handleEmailPasswordLogin}
disabled={isLoading || !email || !password}
className="w-full"
>{t('login.signBtn')}</Button>
</div>
</form>
<div className="mb-3">
<label htmlFor="password" className="my-2 flex items-center justify-between">
<span className="system-md-semibold text-text-secondary">{t('password', { ns: 'login' })}</span>
<Link
href={`/webapp-reset-password?${searchParams.toString()}`}
className={`system-xs-regular ${isEmailSetup ? 'text-components-button-secondary-accent-text' : 'pointer-events-none text-components-button-secondary-accent-text-disabled'}`}
tabIndex={isEmailSetup ? 0 : -1}
aria-disabled={!isEmailSetup}
>
{t('forget', { ns: 'login' })}
</Link>
</label>
<div className="relative mt-1">
<Input
value={password}
onChange={e => setPassword(e.target.value)}
id="password"
onKeyDown={(e) => {
if (e.key === 'Enter')
handleEmailPasswordLogin()
}}
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
placeholder={t('passwordPlaceholder', { ns: 'login' }) || ''}
tabIndex={2}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant="ghost"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? '👀' : '😝'}
</Button>
</div>
</div>
</div>
<div className="mb-2">
<Button
tabIndex={2}
variant="primary"
onClick={handleEmailPasswordLogin}
disabled={isLoading || !email || !password}
className="w-full"
>
{t('signBtn', { ns: 'login' })}
</Button>
</div>
</form>
)
}

View File

@ -1,14 +1,13 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import type { FC } from 'react'
import { useCallback } from 'react'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
import Toast from '@/app/components/base/toast'
import Button from '@/app/components/base/button'
import { SSOProtocol } from '@/types/feature'
import { fetchMembersOAuth2SSOUrl, fetchMembersOIDCSSOUrl, fetchMembersSAMLSSOUrl } from '@/service/share'
import { SSOProtocol } from '@/types/feature'
type SSOAuthProps = {
protocol: SSOProtocol | ''
@ -82,8 +81,8 @@ const SSOAuth: FC<SSOAuthProps> = ({
disabled={isLoading}
className="w-full"
>
<Lock01 className='mr-2 h-5 w-5 text-text-accent-light-mode-only' />
<span className="truncate">{t('login.withSSO')}</span>
<Lock01 className="mr-2 h-5 w-5 text-text-accent-light-mode-only" />
<span className="truncate">{t('withSSO', { ns: 'login' })}</span>
</Button>
)
}

View File

@ -1,28 +1,36 @@
'use client'
import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
import type { PropsWithChildren } from 'react'
import { useTranslation } from 'react-i18next'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { cn } from '@/utils/classnames'
export default function SignInLayout({ children }: PropsWithChildren) {
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
useDocumentTitle(t('login.webapp.login'))
return <>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
{/* <Header /> */}
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
<div className='flex justify-center md:w-[440px] lg:w-[600px]'>
{children}
useDocumentTitle(t('webapp.login', { ns: 'login' }))
return (
<>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
{/* <Header /> */}
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
<div className="flex justify-center md:w-[440px] lg:w-[600px]">
{children}
</div>
</div>
{systemFeatures.branding.enabled === false && (
<div className="system-xs-regular px-8 py-6 text-text-tertiary">
©
{' '}
{new Date().getFullYear()}
{' '}
LangGenius, Inc. All rights reserved.
</div>
)}
</div>
{systemFeatures.branding.enabled === false && <div className='system-xs-regular px-8 py-6 text-text-tertiary'>
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
</div>}
</div>
</div>
</>
</>
)
}

View File

@ -1,16 +1,17 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Link from 'next/link'
import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react'
import Link from 'next/link'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { IS_CE_EDITION } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { LicenseStatus } from '@/types/feature'
import { cn } from '@/utils/classnames'
import MailAndCodeAuth from './components/mail-and-code-auth'
import MailAndPasswordAuth from './components/mail-and-password-auth'
import SSOAuth from './components/sso-auth'
import cn from '@/utils/classnames'
import { LicenseStatus } from '@/types/feature'
import { IS_CE_EDITION } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
const NormalForm = () => {
const { t } = useTranslation()
@ -37,136 +38,173 @@ const NormalForm = () => {
init()
}, [init])
if (isLoading) {
return <div className={
cn(
'flex w-full grow flex-col items-center justify-center',
'px-6',
'md:px-[108px]',
)
}>
<Loading type='area' />
</div>
return (
<div className={
cn(
'flex w-full grow flex-col items-center justify-center',
'px-6',
'md:px-[108px]',
)
}
>
<Loading type="area" />
</div>
)
}
if (systemFeatures.license?.status === LicenseStatus.LOST) {
return <div className='mx-auto mt-8 w-full'>
<div className='relative'>
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className='shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
<RiContractLine className='h-5 w-5' />
<RiErrorWarningFill className='absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary' />
return (
<div className="mx-auto mt-8 w-full">
<div className="relative">
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className="shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow">
<RiContractLine className="h-5 w-5" />
<RiErrorWarningFill className="absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary" />
</div>
<p className="system-sm-medium text-text-primary">{t('licenseLost', { ns: 'login' })}</p>
<p className="system-xs-regular mt-1 text-text-tertiary">{t('licenseLostTip', { ns: 'login' })}</p>
</div>
<p className='system-sm-medium text-text-primary'>{t('login.licenseLost')}</p>
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.licenseLostTip')}</p>
</div>
</div>
</div>
)
}
if (systemFeatures.license?.status === LicenseStatus.EXPIRED) {
return <div className='mx-auto mt-8 w-full'>
<div className='relative'>
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className='shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
<RiContractLine className='h-5 w-5' />
<RiErrorWarningFill className='absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary' />
return (
<div className="mx-auto mt-8 w-full">
<div className="relative">
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className="shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow">
<RiContractLine className="h-5 w-5" />
<RiErrorWarningFill className="absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary" />
</div>
<p className="system-sm-medium text-text-primary">{t('licenseExpired', { ns: 'login' })}</p>
<p className="system-xs-regular mt-1 text-text-tertiary">{t('licenseExpiredTip', { ns: 'login' })}</p>
</div>
<p className='system-sm-medium text-text-primary'>{t('login.licenseExpired')}</p>
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.licenseExpiredTip')}</p>
</div>
</div>
</div>
)
}
if (systemFeatures.license?.status === LicenseStatus.INACTIVE) {
return <div className='mx-auto mt-8 w-full'>
<div className='relative'>
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className='shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
<RiContractLine className='h-5 w-5' />
<RiErrorWarningFill className='absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary' />
return (
<div className="mx-auto mt-8 w-full">
<div className="relative">
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className="shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow">
<RiContractLine className="h-5 w-5" />
<RiErrorWarningFill className="absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary" />
</div>
<p className="system-sm-medium text-text-primary">{t('licenseInactive', { ns: 'login' })}</p>
<p className="system-xs-regular mt-1 text-text-tertiary">{t('licenseInactiveTip', { ns: 'login' })}</p>
</div>
<p className='system-sm-medium text-text-primary'>{t('login.licenseInactive')}</p>
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.licenseInactiveTip')}</p>
</div>
</div>
</div>
)
}
return (
<>
<div className="mx-auto mt-8 w-full">
<div className="mx-auto w-full">
<h2 className="title-4xl-semi-bold text-text-primary">{systemFeatures.branding.enabled ? t('login.pageTitleForE') : t('login.pageTitle')}</h2>
<p className='body-md-regular mt-2 text-text-tertiary'>{t('login.welcome')}</p>
<h2 className="title-4xl-semi-bold text-text-primary">{systemFeatures.branding.enabled ? t('pageTitleForE', { ns: 'login' }) : t('pageTitle', { ns: 'login' })}</h2>
<p className="body-md-regular mt-2 text-text-tertiary">{t('welcome', { ns: 'login' })}</p>
</div>
<div className="relative">
<div className="mt-6 flex flex-col gap-3">
{systemFeatures.sso_enforced_for_signin && <div className='w-full'>
<SSOAuth protocol={systemFeatures.sso_enforced_for_signin_protocol} />
</div>}
{systemFeatures.sso_enforced_for_signin && (
<div className="w-full">
<SSOAuth protocol={systemFeatures.sso_enforced_for_signin_protocol} />
</div>
)}
</div>
{showORLine && <div className="relative mt-6">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className='h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
</div>
<div className="relative flex justify-center">
<span className="system-xs-medium-uppercase px-2 text-text-tertiary">{t('login.or')}</span>
</div>
</div>}
{
(systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login) && <>
{systemFeatures.enable_email_code_login && authType === 'code' && <>
<MailAndCodeAuth />
{systemFeatures.enable_email_password_login && <div className='cursor-pointer py-1 text-center' onClick={() => { updateAuthType('password') }}>
<span className='system-xs-medium text-components-button-secondary-accent-text'>{t('login.usePassword')}</span>
</div>}
</>}
{systemFeatures.enable_email_password_login && authType === 'password' && <>
<MailAndPasswordAuth isEmailSetup={systemFeatures.is_email_setup} />
{systemFeatures.enable_email_code_login && <div className='cursor-pointer py-1 text-center' onClick={() => { updateAuthType('code') }}>
<span className='system-xs-medium text-components-button-secondary-accent-text'>{t('login.useVerificationCode')}</span>
</div>}
</>}
</>
}
{allMethodsAreDisabled && <>
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className='shadows-shadow-lg mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
<RiDoorLockLine className='h-5 w-5' />
</div>
<p className='system-sm-medium text-text-primary'>{t('login.noLoginMethod')}</p>
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.noLoginMethodTip')}</p>
</div>
<div className="relative my-2 py-2">
{showORLine && (
<div className="relative mt-6">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className='h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
<div className="h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent"></div>
</div>
<div className="relative flex justify-center">
<span className="system-xs-medium-uppercase px-2 text-text-tertiary">{t('or', { ns: 'login' })}</span>
</div>
</div>
</>}
{!systemFeatures.branding.enabled && <>
<div className="system-xs-regular mt-2 block w-full text-text-tertiary">
{t('login.tosDesc')}
)}
{
(systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login) && (
<>
{systemFeatures.enable_email_code_login && authType === 'code' && (
<>
<MailAndCodeAuth />
{systemFeatures.enable_email_password_login && (
<div className="cursor-pointer py-1 text-center" onClick={() => { updateAuthType('password') }}>
<span className="system-xs-medium text-components-button-secondary-accent-text">{t('usePassword', { ns: 'login' })}</span>
</div>
)}
</>
)}
{systemFeatures.enable_email_password_login && authType === 'password' && (
<>
<MailAndPasswordAuth isEmailSetup={systemFeatures.is_email_setup} />
{systemFeatures.enable_email_code_login && (
<div className="cursor-pointer py-1 text-center" onClick={() => { updateAuthType('code') }}>
<span className="system-xs-medium text-components-button-secondary-accent-text">{t('useVerificationCode', { ns: 'login' })}</span>
</div>
)}
</>
)}
</>
)
}
{allMethodsAreDisabled && (
<>
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className="shadows-shadow-lg mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow">
<RiDoorLockLine className="h-5 w-5" />
</div>
<p className="system-sm-medium text-text-primary">{t('noLoginMethod', { ns: 'login' })}</p>
<p className="system-xs-regular mt-1 text-text-tertiary">{t('noLoginMethodTip', { ns: 'login' })}</p>
</div>
<div className="relative my-2 py-2">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent"></div>
</div>
</div>
</>
)}
{!systemFeatures.branding.enabled && (
<>
<div className="system-xs-regular mt-2 block w-full text-text-tertiary">
{t('tosDesc', { ns: 'login' })}
&nbsp;
<Link
className='system-xs-medium text-text-secondary hover:underline'
target='_blank' rel='noopener noreferrer'
href='https://dify.ai/terms'
>{t('login.tos')}</Link>
<Link
className="system-xs-medium text-text-secondary hover:underline"
target="_blank"
rel="noopener noreferrer"
href="https://dify.ai/terms"
>
{t('tos', { ns: 'login' })}
</Link>
&nbsp;&&nbsp;
<Link
className='system-xs-medium text-text-secondary hover:underline'
target='_blank' rel='noopener noreferrer'
href='https://dify.ai/privacy'
>{t('login.pp')}</Link>
</div>
{IS_CE_EDITION && <div className="w-hull system-xs-regular mt-2 block text-text-tertiary">
{t('login.goToInit')}
<Link
className="system-xs-medium text-text-secondary hover:underline"
target="_blank"
rel="noopener noreferrer"
href="https://dify.ai/privacy"
>
{t('pp', { ns: 'login' })}
</Link>
</div>
{IS_CE_EDITION && (
<div className="w-hull system-xs-regular mt-2 block text-text-tertiary">
{t('goToInit', { ns: 'login' })}
&nbsp;
<Link
className='system-xs-medium text-text-secondary hover:underline'
href='/install'
>{t('login.setAdminAccount')}</Link>
</div>}
</>}
<Link
className="system-xs-medium text-text-secondary hover:underline"
href="/install"
>
{t('setAdminAccount', { ns: 'login' })}
</Link>
</div>
)}
</>
)}
</div>
</div>

View File

@ -1,15 +1,16 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useGlobalPublicStore } from '@/context/global-public-context'
import AppUnavailable from '@/app/components/base/app-unavailable'
import NormalForm from './normalForm'
import { AccessMode } from '@/models/access-control'
import ExternalMemberSsoAuth from './components/external-member-sso-auth'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
import { webAppLogout } from '@/service/webapp-auth'
import ExternalMemberSsoAuth from './components/external-member-sso-auth'
import NormalForm from './normalForm'
const WebSSOForm: FC = () => {
const { t } = useTranslation()
@ -34,29 +35,37 @@ const WebSSOForm: FC = () => {
}, [getSigninUrl, router, webAppLogout, shareCode])
if (!redirectUrl) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable code={t('share.common.appUnavailable')} unknownReason='redirect url is invalid.' />
</div>
return (
<div className="flex h-full items-center justify-center">
<AppUnavailable code={t('common.appUnavailable', { ns: 'share' })} unknownReason="redirect url is invalid." />
</div>
)
}
if (!systemFeatures.webapp_auth.enabled) {
return <div className="flex h-full items-center justify-center">
<p className='system-xs-regular text-text-tertiary'>{t('login.webapp.disabled')}</p>
</div>
return (
<div className="flex h-full items-center justify-center">
<p className="system-xs-regular text-text-tertiary">{t('webapp.disabled', { ns: 'login' })}</p>
</div>
)
}
if (webAppAccessMode && (webAppAccessMode === AccessMode.ORGANIZATION || webAppAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS)) {
return <div className='w-full max-w-[400px]'>
<NormalForm />
</div>
return (
<div className="w-full max-w-[400px]">
<NormalForm />
</div>
)
}
if (webAppAccessMode && webAppAccessMode === AccessMode.EXTERNAL_MEMBERS)
return <ExternalMemberSsoAuth />
return <div className='flex h-full flex-col items-center justify-center gap-y-4'>
<AppUnavailable className='h-auto w-auto' isUnknownReason={true} />
<span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('share.login.backToHome')}</span>
</div>
return (
<div className="flex h-full flex-col items-center justify-center gap-y-4">
<AppUnavailable className="h-auto w-auto" isUnknownReason={true} />
<span className="system-sm-regular cursor-pointer text-text-tertiary" onClick={backToHome}>{t('login.backToHome', { ns: 'share' })}</span>
</div>
)
}
export default React.memo(WebSSOForm)

View File

@ -1,4 +1,4 @@
import React from 'react'
import * as React from 'react'
import Main from '@/app/components/share/text-generation'
import AuthenticatedLayout from '../../components/authenticated-layout'

View File

@ -1,23 +1,26 @@
'use client'
import type { Area } from 'react-easy-crop'
import React, { useCallback, useState } from 'react'
import type { OnImageInput } from '@/app/components/base/app-icon-picker/ImageInput'
import type { AvatarProps } from '@/app/components/base/avatar'
import type { ImageFile } from '@/types/app'
import { RiDeleteBin5Line, RiPencilLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { RiDeleteBin5Line, RiPencilLine } from '@remixicon/react'
import { updateUserProfile } from '@/service/common'
import { ToastContext } from '@/app/components/base/toast'
import ImageInput, { type OnImageInput } from '@/app/components/base/app-icon-picker/ImageInput'
import Modal from '@/app/components/base/modal'
import Divider from '@/app/components/base/divider'
import Button from '@/app/components/base/button'
import Avatar, { type AvatarProps } from '@/app/components/base/avatar'
import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks'
import type { ImageFile } from '@/types/app'
import ImageInput from '@/app/components/base/app-icon-picker/ImageInput'
import getCroppedImg from '@/app/components/base/app-icon-picker/utils'
import Avatar from '@/app/components/base/avatar'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
import { updateUserProfile } from '@/service/common'
type InputImageInfo = { file: File } | { tempUrl: string; croppedAreaPixels: Area; fileName: string }
type InputImageInfo = { file: File } | { tempUrl: string, croppedAreaPixels: Area, fileName: string }
type AvatarWithEditProps = AvatarProps & { onSave?: () => void }
const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
@ -45,7 +48,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
await updateUserProfile({ url: 'account/avatar', body: { avatar: uploadedFileId } })
setIsShowAvatarPicker(false)
onSave?.()
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
@ -55,7 +58,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
const handleDeleteAvatar = useCallback(async () => {
try {
await updateUserProfile({ url: 'account/avatar', body: { avatar: '' } })
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
setIsShowDeleteConfirm(false)
onSave?.()
}
@ -116,15 +119,17 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
setHoverArea(isRight ? 'right' : 'left')
}}
>
{hoverArea === 'right' && !onAvatarError ? (
<span className="text-xs text-white">
<RiDeleteBin5Line />
</span>
) : (
<span className="text-xs text-white">
<RiPencilLine />
</span>
)}
{hoverArea === 'right' && !onAvatarError
? (
<span className="text-xs text-white">
<RiDeleteBin5Line />
</span>
)
: (
<span className="text-xs text-white">
<RiPencilLine />
</span>
)}
</div>
</div>
</div>
@ -135,16 +140,16 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
isShow={isShowAvatarPicker}
onClose={() => setIsShowAvatarPicker(false)}
>
<ImageInput onImageInput={handleImageInput} cropShape='round' />
<Divider className='m-0' />
<ImageInput onImageInput={handleImageInput} cropShape="round" />
<Divider className="m-0" />
<div className='flex w-full items-center justify-center gap-2 p-3'>
<Button className='w-full' onClick={() => setIsShowAvatarPicker(false)}>
{t('app.iconPicker.cancel')}
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button className="w-full" onClick={() => setIsShowAvatarPicker(false)}>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button variant="primary" className='w-full' disabled={uploading || !inputImageInfo} loading={uploading} onClick={handleSelect}>
{t('app.iconPicker.ok')}
<Button variant="primary" className="w-full" disabled={uploading || !inputImageInfo} loading={uploading} onClick={handleSelect}>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</Modal>
@ -155,16 +160,16 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
isShow={isShowDeleteConfirm}
onClose={() => setIsShowDeleteConfirm(false)}
>
<div className="title-2xl-semi-bold mb-3 text-text-primary">{t('common.avatar.deleteTitle')}</div>
<p className="mb-8 text-text-secondary">{t('common.avatar.deleteDescription')}</p>
<div className="title-2xl-semi-bold mb-3 text-text-primary">{t('avatar.deleteTitle', { ns: 'common' })}</div>
<p className="mb-8 text-text-secondary">{t('avatar.deleteDescription', { ns: 'common' })}</p>
<div className="flex w-full items-center justify-center gap-2">
<Button className="w-full" onClick={() => setIsShowDeleteConfirm(false)}>
{t('common.operation.cancel')}
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button variant="warning" className="w-full" onClick={handleDeleteAvatar}>
{t('common.operation.delete')}
{t('operation.delete', { ns: 'common' })}
</Button>
</div>
</Modal>

View File

@ -1,22 +1,23 @@
import React, { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation'
import { useContext } from 'use-context-selector'
import { ToastContext } from '@/app/components/base/toast'
import type { ResponseError } from '@/service/fetch'
import { RiCloseLine } from '@remixicon/react'
import Modal from '@/app/components/base/modal'
import { noop } from 'es-toolkit/compat'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast'
import {
checkEmailExisted,
resetEmail,
sendVerifyCode,
verifyEmail,
} from '@/service/common'
import { noop } from 'lodash-es'
import { asyncRunSafe } from '@/utils'
import type { ResponseError } from '@/service/fetch'
import { useLogout } from '@/service/use-common'
import { asyncRunSafe } from '@/utils'
type Props = {
show: boolean
@ -116,7 +117,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
}
const isValidEmail = (email: string): boolean => {
const rfc5322emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
const rfc5322emailRegex = /^[\w.!#$%&'*+/=?^`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i
return rfc5322emailRegex.test(email) && email.length <= 254
}
@ -201,176 +202,176 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
<Modal
isShow={show}
onClose={noop}
className='!w-[420px] !p-6'
className="!w-[420px] !p-6"
>
<div className='absolute right-5 top-5 cursor-pointer p-1.5' onClick={onClose}>
<RiCloseLine className='h-5 w-5 text-text-tertiary' />
<div className="absolute right-5 top-5 cursor-pointer p-1.5" onClick={onClose}>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
{step === STEP.start && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.title')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-medium text-text-warning'>{t('common.account.changeEmail.authTip')}</div>
<div className='body-md-regular text-text-secondary'>
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.title', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="body-md-medium text-text-warning">{t('account.changeEmail.authTip', { ns: 'common' })}</div>
<div className="body-md-regular text-text-secondary">
<Trans
i18nKey="common.account.changeEmail.content1"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
values={{ email }}
/>
</div>
</div>
<div className='pt-3'></div>
<div className='space-y-2'>
<div className="pt-3"></div>
<div className="space-y-2">
<Button
className='!w-full'
variant='primary'
className="!w-full"
variant="primary"
onClick={sendCodeToOriginEmail}
>
{t('common.account.changeEmail.sendVerifyCode')}
{t('account.changeEmail.sendVerifyCode', { ns: 'common' })}
</Button>
<Button
className='!w-full'
className="!w-full"
onClick={onClose}
>
{t('common.operation.cancel')}
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
</>
)}
{step === STEP.verifyOrigin && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.verifyEmail')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-regular text-text-secondary'>
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.verifyEmail', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="body-md-regular text-text-secondary">
<Trans
i18nKey="common.account.changeEmail.content2"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
values={{ email }}
/>
</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.account.changeEmail.codeLabel')}</div>
<div className="pt-3">
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
<Input
className='!w-full'
placeholder={t('common.account.changeEmail.codePlaceholder')}
className="!w-full"
placeholder={t('account.changeEmail.codePlaceholder', { ns: 'common' })}
value={code}
onChange={e => setCode(e.target.value)}
maxLength={6}
/>
</div>
<div className='mt-3 space-y-2'>
<div className="mt-3 space-y-2">
<Button
disabled={code.length !== 6}
className='!w-full'
variant='primary'
className="!w-full"
variant="primary"
onClick={handleVerifyOriginEmail}
>
{t('common.account.changeEmail.continue')}
{t('account.changeEmail.continue', { ns: 'common' })}
</Button>
<Button
className='!w-full'
className="!w-full"
onClick={onClose}
>
{t('common.operation.cancel')}
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
<div className='system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary'>
<span>{t('common.account.changeEmail.resendTip')}</span>
<div className="system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary">
<span>{t('account.changeEmail.resendTip', { ns: 'common' })}</span>
{time > 0 && (
<span>{t('common.account.changeEmail.resendCount', { count: time })}</span>
<span>{t('account.changeEmail.resendCount', { ns: 'common', count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToOriginEmail} className='system-xs-medium cursor-pointer text-text-accent-secondary'>{t('common.account.changeEmail.resend')}</span>
<span onClick={sendCodeToOriginEmail} className="system-xs-medium cursor-pointer text-text-accent-secondary">{t('account.changeEmail.resend', { ns: 'common' })}</span>
)}
</div>
</>
)}
{step === STEP.newEmail && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.newEmail')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-regular text-text-secondary'>{t('common.account.changeEmail.content3')}</div>
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.newEmail', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="body-md-regular text-text-secondary">{t('account.changeEmail.content3', { ns: 'common' })}</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.account.changeEmail.emailLabel')}</div>
<div className="pt-3">
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">{t('account.changeEmail.emailLabel', { ns: 'common' })}</div>
<Input
className='!w-full'
placeholder={t('common.account.changeEmail.emailPlaceholder')}
className="!w-full"
placeholder={t('account.changeEmail.emailPlaceholder', { ns: 'common' })}
value={mail}
onChange={e => handleNewEmailValueChange(e.target.value)}
destructive={newEmailExited || unAvailableEmail}
/>
{newEmailExited && (
<div className='body-xs-regular mt-1 py-0.5 text-text-destructive'>{t('common.account.changeEmail.existingEmail')}</div>
<div className="body-xs-regular mt-1 py-0.5 text-text-destructive">{t('account.changeEmail.existingEmail', { ns: 'common' })}</div>
)}
{unAvailableEmail && (
<div className='body-xs-regular mt-1 py-0.5 text-text-destructive'>{t('common.account.changeEmail.unAvailableEmail')}</div>
<div className="body-xs-regular mt-1 py-0.5 text-text-destructive">{t('account.changeEmail.unAvailableEmail', { ns: 'common' })}</div>
)}
</div>
<div className='mt-3 space-y-2'>
<div className="mt-3 space-y-2">
<Button
disabled={!mail || newEmailExited || unAvailableEmail || isCheckingEmail || !isValidEmail(mail)}
className='!w-full'
variant='primary'
className="!w-full"
variant="primary"
onClick={sendCodeToNewEmail}
>
{t('common.account.changeEmail.sendVerifyCode')}
{t('account.changeEmail.sendVerifyCode', { ns: 'common' })}
</Button>
<Button
className='!w-full'
className="!w-full"
onClick={onClose}
>
{t('common.operation.cancel')}
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
</>
)}
{step === STEP.verifyNew && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.verifyNew')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-regular text-text-secondary'>
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.verifyNew', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="body-md-regular text-text-secondary">
<Trans
i18nKey="common.account.changeEmail.content4"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
values={{ email: mail }}
/>
</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.account.changeEmail.codeLabel')}</div>
<div className="pt-3">
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
<Input
className='!w-full'
placeholder={t('common.account.changeEmail.codePlaceholder')}
className="!w-full"
placeholder={t('account.changeEmail.codePlaceholder', { ns: 'common' })}
value={code}
onChange={e => setCode(e.target.value)}
maxLength={6}
/>
</div>
<div className='mt-3 space-y-2'>
<div className="mt-3 space-y-2">
<Button
disabled={code.length !== 6}
className='!w-full'
variant='primary'
className="!w-full"
variant="primary"
onClick={submitNewEmail}
>
{t('common.account.changeEmail.changeTo', { email: mail })}
{t('account.changeEmail.changeTo', { ns: 'common', email: mail })}
</Button>
<Button
className='!w-full'
className="!w-full"
onClick={onClose}
>
{t('common.operation.cancel')}
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
<div className='system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary'>
<span>{t('common.account.changeEmail.resendTip')}</span>
<div className="system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary">
<span>{t('account.changeEmail.resendTip', { ns: 'common' })}</span>
{time > 0 && (
<span>{t('common.account.changeEmail.resendCount', { count: time })}</span>
<span>{t('account.changeEmail.resendCount', { ns: 'common', count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToNewEmail} className='system-xs-medium cursor-pointer text-text-accent-secondary'>{t('common.account.changeEmail.resend')}</span>
<span onClick={sendCodeToNewEmail} className="system-xs-medium cursor-pointer text-text-accent-secondary">{t('account.changeEmail.resend', { ns: 'common' })}</span>
)}
</div>
</>

View File

@ -1,30 +1,29 @@
'use client'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { IItem } from '@/app/components/header/account-setting/collapse'
import type { App } from '@/types/app'
import {
RiGraduationCapFill,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import DeleteAccount from '../delete-account'
import AvatarWithEdit from './AvatarWithEdit'
import Collapse from '@/app/components/header/account-setting/collapse'
import type { IItem } from '@/app/components/header/account-setting/collapse'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import { updateUserProfile } from '@/service/common'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { ToastContext } from '@/app/components/base/toast'
import AppIcon from '@/app/components/base/app-icon'
import { IS_CE_EDITION } from '@/config'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import PremiumBadge from '@/app/components/base/premium-badge'
import { ToastContext } from '@/app/components/base/toast'
import Collapse from '@/app/components/header/account-setting/collapse'
import { IS_CE_EDITION, validPassword } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import EmailChangeModal from './email-change-modal'
import { validPassword } from '@/config'
import type { App } from '@/types/app'
import { useProviderContext } from '@/context/provider-context'
import { updateUserProfile } from '@/service/common'
import { useAppList } from '@/service/use-apps'
import DeleteAccount from '../delete-account'
import AvatarWithEdit from './AvatarWithEdit'
import EmailChangeModal from './email-change-modal'
const titleClassName = `
system-sm-semibold text-text-secondary
@ -62,7 +61,7 @@ export default function AccountPage() {
try {
setEditing(true)
await updateUserProfile({ url: 'account/name', body: { name: editName } })
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
mutateUserProfile()
setEditNameModalVisible(false)
setEditing(false)
@ -81,15 +80,15 @@ export default function AccountPage() {
}
const valid = () => {
if (!password.trim()) {
showErrorMessage(t('login.error.passwordEmpty'))
showErrorMessage(t('error.passwordEmpty', { ns: 'login' }))
return false
}
if (!validPassword.test(password)) {
showErrorMessage(t('login.error.passwordInvalid'))
showErrorMessage(t('error.passwordInvalid', { ns: 'login' }))
return false
}
if (password !== confirmPassword) {
showErrorMessage(t('common.account.notEqual'))
showErrorMessage(t('account.notEqual', { ns: 'common' }))
return false
}
@ -113,7 +112,7 @@ export default function AccountPage() {
repeat_new_password: confirmPassword,
},
})
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
mutateUserProfile()
setEditPasswordModalVisible(false)
resetPasswordForm()
@ -129,111 +128,112 @@ export default function AccountPage() {
const renderAppItem = (item: IItem) => {
const { icon, icon_background, icon_type, icon_url } = item as any
return (
<div className='flex px-3 py-1'>
<div className='mr-3'>
<div className="flex px-3 py-1">
<div className="mr-3">
<AppIcon
size='tiny'
size="tiny"
iconType={icon_type}
icon={icon}
background={icon_background}
imageUrl={icon_url}
/>
</div>
<div className='system-sm-medium mt-[3px] text-text-secondary'>{item.name}</div>
<div className="system-sm-medium mt-[3px] text-text-secondary">{item.name}</div>
</div>
)
}
return (
<>
<div className='pb-3 pt-2'>
<h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</h4>
<div className="pb-3 pt-2">
<h4 className="title-2xl-semi-bold text-text-primary">{t('account.myAccount', { ns: 'common' })}</h4>
</div>
<div className='mb-8 flex items-center rounded-xl bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-6'>
<div className="mb-8 flex items-center rounded-xl bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-6">
<AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={mutateUserProfile} size={64} />
<div className='ml-4'>
<p className='system-xl-semibold text-text-primary'>
<div className="ml-4">
<p className="system-xl-semibold text-text-primary">
{userProfile.name}
{isEducationAccount && (
<PremiumBadge size='s' color='blue' className='ml-1 !px-2'>
<RiGraduationCapFill className='mr-1 h-3 w-3' />
<span className='system-2xs-medium'>EDU</span>
<PremiumBadge size="s" color="blue" className="ml-1 !px-2">
<RiGraduationCapFill className="mr-1 h-3 w-3" />
<span className="system-2xs-medium">EDU</span>
</PremiumBadge>
)}
</p>
<p className='system-xs-regular text-text-tertiary'>{userProfile.email}</p>
<p className="system-xs-regular text-text-tertiary">{userProfile.email}</p>
</div>
</div>
<div className='mb-8'>
<div className={titleClassName}>{t('common.account.name')}</div>
<div className='mt-2 flex w-full items-center justify-between gap-2'>
<div className='system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled '>
<span className='pl-1'>{userProfile.name}</span>
<div className="mb-8">
<div className={titleClassName}>{t('account.name', { ns: 'common' })}</div>
<div className="mt-2 flex w-full items-center justify-between gap-2">
<div className="system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled ">
<span className="pl-1">{userProfile.name}</span>
</div>
<div className='system-sm-medium cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text' onClick={handleEditName}>
{t('common.operation.edit')}
<div className="system-sm-medium cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text" onClick={handleEditName}>
{t('operation.edit', { ns: 'common' })}
</div>
</div>
</div>
<div className='mb-8'>
<div className={titleClassName}>{t('common.account.email')}</div>
<div className='mt-2 flex w-full items-center justify-between gap-2'>
<div className='system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled '>
<span className='pl-1'>{userProfile.email}</span>
<div className="mb-8">
<div className={titleClassName}>{t('account.email', { ns: 'common' })}</div>
<div className="mt-2 flex w-full items-center justify-between gap-2">
<div className="system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled ">
<span className="pl-1">{userProfile.email}</span>
</div>
{systemFeatures.enable_change_email && (
<div className='system-sm-medium cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text' onClick={() => setShowUpdateEmail(true)}>
{t('common.operation.change')}
<div className="system-sm-medium cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text" onClick={() => setShowUpdateEmail(true)}>
{t('operation.change', { ns: 'common' })}
</div>
)}
</div>
</div>
{
systemFeatures.enable_email_password_login && (
<div className='mb-8 flex justify-between gap-2'>
<div className="mb-8 flex justify-between gap-2">
<div>
<div className='system-sm-semibold mb-1 text-text-secondary'>{t('common.account.password')}</div>
<div className='body-xs-regular mb-2 text-text-tertiary'>{t('common.account.passwordTip')}</div>
<div className="system-sm-semibold mb-1 text-text-secondary">{t('account.password', { ns: 'common' })}</div>
<div className="body-xs-regular mb-2 text-text-tertiary">{t('account.passwordTip', { ns: 'common' })}</div>
</div>
<Button onClick={() => setEditPasswordModalVisible(true)}>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</Button>
<Button onClick={() => setEditPasswordModalVisible(true)}>{userProfile.is_password_set ? t('account.resetPassword', { ns: 'common' }) : t('account.setPassword', { ns: 'common' })}</Button>
</div>
)
}
<div className='mb-6 border-[1px] border-divider-subtle' />
<div className='mb-8'>
<div className={titleClassName}>{t('common.account.langGeniusAccount')}</div>
<div className={descriptionClassName}>{t('common.account.langGeniusAccountTip')}</div>
<div className="mb-6 border-[1px] border-divider-subtle" />
<div className="mb-8">
<div className={titleClassName}>{t('account.langGeniusAccount', { ns: 'common' })}</div>
<div className={descriptionClassName}>{t('account.langGeniusAccountTip', { ns: 'common' })}</div>
{!!apps.length && (
<Collapse
title={`${t('common.account.showAppLength', { length: apps.length })}`}
title={`${t('account.showAppLength', { ns: 'common', length: apps.length })}`}
items={apps.map((app: App) => ({ ...app, key: app.id, name: app.name }))}
renderItem={renderAppItem}
wrapperClassName='mt-2'
wrapperClassName="mt-2"
/>
)}
{!IS_CE_EDITION && <Button className='mt-2 text-components-button-destructive-secondary-text' onClick={() => setShowDeleteAccountModal(true)}>{t('common.account.delete')}</Button>}
{!IS_CE_EDITION && <Button className="mt-2 text-components-button-destructive-secondary-text" onClick={() => setShowDeleteAccountModal(true)}>{t('account.delete', { ns: 'common' })}</Button>}
</div>
{
editNameModalVisible && (
<Modal
isShow
onClose={() => setEditNameModalVisible(false)}
className='!w-[420px] !p-6'
className="!w-[420px] !p-6"
>
<div className='title-2xl-semi-bold mb-6 text-text-primary'>{t('common.account.editName')}</div>
<div className={titleClassName}>{t('common.account.name')}</div>
<Input className='mt-2'
<div className="title-2xl-semi-bold mb-6 text-text-primary">{t('account.editName', { ns: 'common' })}</div>
<div className={titleClassName}>{t('account.name', { ns: 'common' })}</div>
<Input
className="mt-2"
value={editName}
onChange={e => setEditName(e.target.value)}
/>
<div className='mt-10 flex justify-end'>
<Button className='mr-2' onClick={() => setEditNameModalVisible(false)}>{t('common.operation.cancel')}</Button>
<div className="mt-10 flex justify-end">
<Button className="mr-2" onClick={() => setEditNameModalVisible(false)}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button
disabled={editing || !editName}
variant='primary'
variant="primary"
onClick={handleSaveName}
>
{t('common.operation.save')}
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</Modal>
@ -247,13 +247,13 @@ export default function AccountPage() {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}
className='!w-[420px] !p-6'
className="!w-[420px] !p-6"
>
<div className='title-2xl-semi-bold mb-6 text-text-primary'>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</div>
<div className="title-2xl-semi-bold mb-6 text-text-primary">{userProfile.is_password_set ? t('account.resetPassword', { ns: 'common' }) : t('account.setPassword', { ns: 'common' })}</div>
{userProfile.is_password_set && (
<>
<div className={titleClassName}>{t('common.account.currentPassword')}</div>
<div className='relative mt-2'>
<div className={titleClassName}>{t('account.currentPassword', { ns: 'common' })}</div>
<div className="relative mt-2">
<Input
type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword}
@ -263,7 +263,7 @@ export default function AccountPage() {
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
variant="ghost"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
>
{showCurrentPassword ? '👀' : '😝'}
@ -272,10 +272,10 @@ export default function AccountPage() {
</div>
</>
)}
<div className='system-sm-semibold mt-8 text-text-secondary'>
{userProfile.is_password_set ? t('common.account.newPassword') : t('common.account.password')}
<div className="system-sm-semibold mt-8 text-text-secondary">
{userProfile.is_password_set ? t('account.newPassword', { ns: 'common' }) : t('account.password', { ns: 'common' })}
</div>
<div className='relative mt-2'>
<div className="relative mt-2">
<Input
type={showPassword ? 'text' : 'password'}
value={password}
@ -284,15 +284,15 @@ export default function AccountPage() {
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
variant="ghost"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? '👀' : '😝'}
</Button>
</div>
</div>
<div className='system-sm-semibold mt-8 text-text-secondary'>{t('common.account.confirmPassword')}</div>
<div className='relative mt-2'>
<div className="system-sm-semibold mt-8 text-text-secondary">{t('account.confirmPassword', { ns: 'common' })}</div>
<div className="relative mt-2">
<Input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
@ -301,24 +301,29 @@ export default function AccountPage() {
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
variant="ghost"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? '👀' : '😝'}
</Button>
</div>
</div>
<div className='mt-10 flex justify-end'>
<Button className='mr-2' onClick={() => {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}>{t('common.operation.cancel')}</Button>
<div className="mt-10 flex justify-end">
<Button
className="mr-2"
onClick={() => {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
disabled={editing}
variant='primary'
variant="primary"
onClick={handleSavePassword}
>
{userProfile.is_password_set ? t('common.operation.reset') : t('common.operation.save')}
{userProfile.is_password_set ? t('operation.reset', { ns: 'common' }) : t('operation.save', { ns: 'common' })}
</Button>
</div>
</Modal>

View File

@ -1,18 +1,18 @@
'use client'
import { useTranslation } from 'react-i18next'
import { Fragment } from 'react'
import { useRouter } from 'next/navigation'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import {
RiGraduationCapFill,
} from '@remixicon/react'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { useRouter } from 'next/navigation'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils'
import Avatar from '@/app/components/base/avatar'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
import PremiumBadge from '@/app/components/base/premium-badge'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useLogout } from '@/service/use-common'
import { resetUser } from '@/app/components/base/amplitude/utils'
export type IAppSelector = {
isMobile: boolean
@ -70,31 +70,31 @@ export default function AppSelector() {
"
>
<MenuItem>
<div className='p-1'>
<div className='flex flex-nowrap items-center px-3 py-2'>
<div className='grow'>
<div className='system-md-medium break-all text-text-primary'>
<div className="p-1">
<div className="flex flex-nowrap items-center px-3 py-2">
<div className="grow">
<div className="system-md-medium break-all text-text-primary">
{userProfile.name}
{isEducationAccount && (
<PremiumBadge size='s' color='blue' className='ml-1 !px-2'>
<RiGraduationCapFill className='mr-1 h-3 w-3' />
<span className='system-2xs-medium'>EDU</span>
<PremiumBadge size="s" color="blue" className="ml-1 !px-2">
<RiGraduationCapFill className="mr-1 h-3 w-3" />
<span className="system-2xs-medium">EDU</span>
</PremiumBadge>
)}
</div>
<div className='system-xs-regular break-all text-text-tertiary'>{userProfile.email}</div>
<div className="system-xs-regular break-all text-text-tertiary">{userProfile.email}</div>
</div>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} />
</div>
</div>
</MenuItem>
<MenuItem>
<div className='p-1' onClick={() => handleLogout()}>
<div className="p-1" onClick={() => handleLogout()}>
<div
className='group flex h-9 cursor-pointer items-center justify-start rounded-lg px-3 hover:bg-state-base-hover'
className="group flex h-9 cursor-pointer items-center justify-start rounded-lg px-3 hover:bg-state-base-hover"
>
<LogOut01 className='mr-1 flex h-4 w-4 text-text-tertiary' />
<div className='text-[14px] font-normal text-text-secondary'>{t('common.userProfile.logout')}</div>
<LogOut01 className="mr-1 flex h-4 w-4 text-text-tertiary" />
<div className="text-[14px] font-normal text-text-secondary">{t('userProfile.logout', { ns: 'common' })}</div>
</div>
</div>
</MenuItem>

View File

@ -1,11 +1,11 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useState } from 'react'
import Link from 'next/link'
import { useSendDeleteAccountEmail } from '../state'
import { useAppContext } from '@/context/app-context'
import Input from '@/app/components/base/input'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import { useAppContext } from '@/context/app-context'
import { useSendDeleteAccountEmail } from '../state'
type DeleteAccountProps = {
onCancel: () => void
@ -28,21 +28,26 @@ export default function CheckEmail(props: DeleteAccountProps) {
catch (error) { console.error(error) }
}, [getDeleteEmailVerifyCode, props])
return <>
<div className='body-md-medium py-1 text-text-destructive'>
{t('common.account.deleteTip')}
</div>
<div className='body-md-regular pb-2 pt-1 text-text-secondary'>
{t('common.account.deletePrivacyLinkTip')}
<Link href='https://dify.ai/privacy' className='text-text-accent'>{t('common.account.deletePrivacyLink')}</Link>
</div>
<label className='system-sm-semibold mb-1 mt-3 flex h-6 items-center text-text-secondary'>{t('common.account.deleteLabel')}</label>
<Input placeholder={t('common.account.deletePlaceholder') as string} onChange={(e) => {
setUserInputEmail(e.target.value)
}} />
<div className='mt-3 flex w-full flex-col gap-2'>
<Button className='w-full' disabled={userInputEmail !== userProfile.email || isSendingEmail} loading={isSendingEmail} variant='primary' onClick={handleConfirm}>{t('common.account.sendVerificationButton')}</Button>
<Button className='w-full' onClick={props.onCancel}>{t('common.operation.cancel')}</Button>
</div>
</>
return (
<>
<div className="body-md-medium py-1 text-text-destructive">
{t('account.deleteTip', { ns: 'common' })}
</div>
<div className="body-md-regular pb-2 pt-1 text-text-secondary">
{t('account.deletePrivacyLinkTip', { ns: 'common' })}
<Link href="https://dify.ai/privacy" className="text-text-accent">{t('account.deletePrivacyLink', { ns: 'common' })}</Link>
</div>
<label className="system-sm-semibold mb-1 mt-3 flex h-6 items-center text-text-secondary">{t('account.deleteLabel', { ns: 'common' })}</label>
<Input
placeholder={t('account.deletePlaceholder', { ns: 'common' }) as string}
onChange={(e) => {
setUserInputEmail(e.target.value)
}}
/>
<div className="mt-3 flex w-full flex-col gap-2">
<Button className="w-full" disabled={userInputEmail !== userProfile.email || isSendingEmail} loading={isSendingEmail} variant="primary" onClick={handleConfirm}>{t('account.sendVerificationButton', { ns: 'common' })}</Button>
<Button className="w-full" onClick={props.onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
</div>
</>
)
}

View File

@ -1,14 +1,14 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useDeleteAccountFeedback } from '../state'
import { useAppContext } from '@/context/app-context'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CustomDialog from '@/app/components/base/dialog'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
import { useLogout } from '@/service/use-common'
import { useDeleteAccountFeedback } from '../state'
type DeleteAccountProps = {
onCancel: () => void
@ -28,7 +28,7 @@ export default function FeedBack(props: DeleteAccountProps) {
await logout()
// Tokens are now stored in cookies and cleared by backend
router.push('/signin')
Toast.notify({ type: 'info', message: t('common.account.deleteSuccessTip') })
Toast.notify({ type: 'info', message: t('account.deleteSuccessTip', { ns: 'common' }) })
}
catch (error) { console.error(error) }
}, [router, t])
@ -46,20 +46,27 @@ export default function FeedBack(props: DeleteAccountProps) {
props.onCancel()
handleSuccess()
}, [handleSuccess, props])
return <CustomDialog
show={true}
onClose={props.onCancel}
title={t('common.account.feedbackTitle')}
className="max-w-[480px]"
footer={false}
>
<label className='system-sm-semibold mb-1 mt-3 flex items-center text-text-secondary'>{t('common.account.feedbackLabel')}</label>
<Textarea rows={6} value={userFeedback} placeholder={t('common.account.feedbackPlaceholder') as string} onChange={(e) => {
setUserFeedback(e.target.value)
}} />
<div className='mt-3 flex w-full flex-col gap-2'>
<Button className='w-full' loading={isPending} variant='primary' onClick={handleSubmit}>{t('common.operation.submit')}</Button>
<Button className='w-full' onClick={handleSkip}>{t('common.operation.skip')}</Button>
</div>
</CustomDialog>
return (
<CustomDialog
show={true}
onClose={props.onCancel}
title={t('account.feedbackTitle', { ns: 'common' })}
className="max-w-[480px]"
footer={false}
>
<label className="system-sm-semibold mb-1 mt-3 flex items-center text-text-secondary">{t('account.feedbackLabel', { ns: 'common' })}</label>
<Textarea
rows={6}
value={userFeedback}
placeholder={t('account.feedbackPlaceholder', { ns: 'common' }) as string}
onChange={(e) => {
setUserFeedback(e.target.value)
}}
/>
<div className="mt-3 flex w-full flex-col gap-2">
<Button className="w-full" loading={isPending} variant="primary" onClick={handleSubmit}>{t('operation.submit', { ns: 'common' })}</Button>
<Button className="w-full" onClick={handleSkip}>{t('operation.skip', { ns: 'common' })}</Button>
</div>
</CustomDialog>
)
}

View File

@ -1,13 +1,13 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { useAccountDeleteStore, useConfirmDeleteAccount, useSendDeleteAccountEmail } from '../state'
import Input from '@/app/components/base/input'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Countdown from '@/app/components/signin/countdown'
import { useAccountDeleteStore, useConfirmDeleteAccount, useSendDeleteAccountEmail } from '../state'
const CODE_EXP = /[A-Za-z\d]{6}/gi
const CODE_EXP = /[A-Z\d]{6}/gi
type DeleteAccountProps = {
onCancel: () => void
@ -34,22 +34,29 @@ export default function VerifyEmail(props: DeleteAccountProps) {
}
catch (error) { console.error(error) }
}, [emailToken, verificationCode, confirmDeleteAccount, props])
return <>
<div className='body-md-medium pt-1 text-text-destructive'>
{t('common.account.deleteTip')}
</div>
<div className='body-md-regular pb-2 pt-1 text-text-secondary'>
{t('common.account.deletePrivacyLinkTip')}
<Link href='https://dify.ai/privacy' className='text-text-accent'>{t('common.account.deletePrivacyLink')}</Link>
</div>
<label className='system-sm-semibold mb-1 mt-3 flex h-6 items-center text-text-secondary'>{t('common.account.verificationLabel')}</label>
<Input minLength={6} maxLength={6} placeholder={t('common.account.verificationPlaceholder') as string} onChange={(e) => {
setVerificationCode(e.target.value)
}} />
<div className='mt-3 flex w-full flex-col gap-2'>
<Button className='w-full' disabled={shouldButtonDisabled} loading={isDeleting} variant='warning' onClick={handleConfirm}>{t('common.account.permanentlyDeleteButton')}</Button>
<Button className='w-full' onClick={props.onCancel}>{t('common.operation.cancel')}</Button>
<Countdown onResend={sendEmail} />
</div>
</>
return (
<>
<div className="body-md-medium pt-1 text-text-destructive">
{t('account.deleteTip', { ns: 'common' })}
</div>
<div className="body-md-regular pb-2 pt-1 text-text-secondary">
{t('account.deletePrivacyLinkTip', { ns: 'common' })}
<Link href="https://dify.ai/privacy" className="text-text-accent">{t('account.deletePrivacyLink', { ns: 'common' })}</Link>
</div>
<label className="system-sm-semibold mb-1 mt-3 flex h-6 items-center text-text-secondary">{t('account.verificationLabel', { ns: 'common' })}</label>
<Input
minLength={6}
maxLength={6}
placeholder={t('account.verificationPlaceholder', { ns: 'common' }) as string}
onChange={(e) => {
setVerificationCode(e.target.value)
}}
/>
<div className="mt-3 flex w-full flex-col gap-2">
<Button className="w-full" disabled={shouldButtonDisabled} loading={isDeleting} variant="warning" onClick={handleConfirm}>{t('account.permanentlyDeleteButton', { ns: 'common' })}</Button>
<Button className="w-full" onClick={props.onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
<Countdown onResend={sendEmail} />
</div>
</>
)
}

View File

@ -1,11 +1,11 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useState } from 'react'
import CheckEmail from './components/check-email'
import VerifyEmail from './components/verify-email'
import FeedBack from './components/feed-back'
import { useTranslation } from 'react-i18next'
import CustomDialog from '@/app/components/base/dialog'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import CheckEmail from './components/check-email'
import FeedBack from './components/feed-back'
import VerifyEmail from './components/verify-email'
type DeleteAccountProps = {
onCancel: () => void
@ -29,16 +29,23 @@ export default function DeleteAccount(props: DeleteAccountProps) {
if (showFeedbackDialog)
return <FeedBack onCancel={props.onCancel} onConfirm={props.onConfirm} />
return <CustomDialog
show={true}
onClose={props.onCancel}
title={t('common.account.delete')}
className="max-w-[480px]"
footer={false}
>
{!showVerifyEmail && <CheckEmail onCancel={props.onCancel} onConfirm={handleEmailCheckSuccess} />}
{showVerifyEmail && <VerifyEmail onCancel={props.onCancel} onConfirm={() => {
setShowFeedbackDialog(true)
}} />}
</CustomDialog>
return (
<CustomDialog
show={true}
onClose={props.onCancel}
title={t('account.delete', { ns: 'common' })}
className="max-w-[480px]"
footer={false}
>
{!showVerifyEmail && <CheckEmail onCancel={props.onCancel} onConfirm={handleEmailCheckSuccess} />}
{showVerifyEmail && (
<VerifyEmail
onCancel={props.onCancel}
onConfirm={() => {
setShowFeedbackDialog(true)
}}
/>
)}
</CustomDialog>
)
}

Some files were not shown because too many files have changed in this diff Show More