Compare commits
7 Commits
4724678035
...
40540ab8a6
| Author | SHA1 | Date | |
|---|---|---|---|
| 40540ab8a6 | |||
| 75fee7c725 | |||
| d0bbd4a20f | |||
| 12265e09ec | |||
| 04d8f9e3bf | |||
| 4f28f102e0 | |||
| cfaa1cb657 |
@@ -3,7 +3,6 @@ import ProtectedRoute from './auth/ProtectedRoute'
|
|||||||
import AppLayout from './layouts/AppLayout'
|
import AppLayout from './layouts/AppLayout'
|
||||||
import ChatPage from './pages/ChatPage'
|
import ChatPage from './pages/ChatPage'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import WorkspacePage from './pages/WorkspacePage'
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -14,7 +13,7 @@ export default function App() {
|
|||||||
path="workspace"
|
path="workspace"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<WorkspacePage />
|
<ChatPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
|
|||||||
import type { PropsWithChildren } from 'react'
|
import type { PropsWithChildren } from 'react'
|
||||||
import { clearAuthSession, getAuthSession, isAccessTokenStale, setAuthSession } from '../lib/auth-storage'
|
import { clearAuthSession, getAuthSession, isAccessTokenStale, setAuthSession } from '../lib/auth-storage'
|
||||||
import type { AuthSession } from '../lib/auth-storage'
|
import type { AuthSession } from '../lib/auth-storage'
|
||||||
import { loginCenter, logoutCenter, refreshCenter } from '../lib/center-auth-client'
|
import { loginCenter, logoutCenter, meGuildsCenter, refreshCenter } from '../lib/center-auth-client'
|
||||||
import { AuthContext } from './auth-context'
|
import { AuthContext } from './auth-context'
|
||||||
import type { AuthContextValue } from './auth-context'
|
import type { AuthContextValue } from './auth-context'
|
||||||
|
|
||||||
@@ -13,15 +13,15 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
() => ({
|
() => ({
|
||||||
session,
|
session,
|
||||||
isAuthed: !!session,
|
isAuthed: !!session,
|
||||||
login: async (centerApiBase: string, centerApiKey: string, email: string, password: string) => {
|
login: async (centerApiBase: string, email: string, password: string) => {
|
||||||
const next = await loginCenter(centerApiBase, centerApiKey, { email, password })
|
const next = await loginCenter(centerApiBase, { email, password })
|
||||||
setAuthSession(next)
|
setAuthSession(next)
|
||||||
setSession(next)
|
setSession(next)
|
||||||
},
|
},
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
if (session?.refreshToken) {
|
if (session?.refreshToken) {
|
||||||
try {
|
try {
|
||||||
await logoutCenter(session.centerApiBase, session.centerApiKey, session.refreshToken)
|
await logoutCenter(session.centerApiBase, session.refreshToken)
|
||||||
} catch {
|
} catch {
|
||||||
// noop
|
// noop
|
||||||
}
|
}
|
||||||
@@ -33,17 +33,45 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
if (!session) return null
|
if (!session) return null
|
||||||
if (!isAccessTokenStale(session.accessToken)) return session.accessToken
|
if (!isAccessTokenStale(session.accessToken)) return session.accessToken
|
||||||
|
|
||||||
const refreshed = await refreshCenter(session.centerApiBase, session.centerApiKey, session.refreshToken)
|
const refreshed = await refreshCenter(session.centerApiBase, session.refreshToken)
|
||||||
const next: AuthSession = {
|
const next: AuthSession = {
|
||||||
...session,
|
...session,
|
||||||
accessToken: refreshed.accessToken,
|
accessToken: refreshed.accessToken,
|
||||||
refreshToken: refreshed.refreshToken,
|
refreshToken: refreshed.refreshToken,
|
||||||
tokenType: refreshed.tokenType,
|
tokenType: refreshed.tokenType,
|
||||||
|
expiresIn: refreshed.expiresIn,
|
||||||
}
|
}
|
||||||
setAuthSession(next)
|
setAuthSession(next)
|
||||||
setSession(next)
|
setSession(next)
|
||||||
return next.accessToken
|
return next.accessToken
|
||||||
},
|
},
|
||||||
|
refreshGuilds: async () => {
|
||||||
|
if (!session) return
|
||||||
|
let accessToken = session.accessToken
|
||||||
|
let refreshToken = session.refreshToken
|
||||||
|
let tokenType = session.tokenType
|
||||||
|
let expiresIn = session.expiresIn
|
||||||
|
if (isAccessTokenStale(session.accessToken)) {
|
||||||
|
const refreshed = await refreshCenter(session.centerApiBase, session.refreshToken)
|
||||||
|
accessToken = refreshed.accessToken
|
||||||
|
refreshToken = refreshed.refreshToken
|
||||||
|
tokenType = refreshed.tokenType
|
||||||
|
expiresIn = refreshed.expiresIn
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildData = await meGuildsCenter(session.centerApiBase, accessToken)
|
||||||
|
const next: AuthSession = {
|
||||||
|
...session,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
tokenType,
|
||||||
|
expiresIn,
|
||||||
|
guilds: guildData.guilds,
|
||||||
|
guildAccessTokens: guildData.guildAccessTokens,
|
||||||
|
}
|
||||||
|
setAuthSession(next)
|
||||||
|
setSession(next)
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[session],
|
[session],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import type { AuthSession } from '../lib/auth-storage'
|
|||||||
export type AuthContextValue = {
|
export type AuthContextValue = {
|
||||||
session: AuthSession | null
|
session: AuthSession | null
|
||||||
isAuthed: boolean
|
isAuthed: boolean
|
||||||
login: (centerApiBase: string, centerApiKey: string, email: string, password: string) => Promise<void>
|
login: (centerApiBase: string, email: string, password: string) => Promise<void>
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
ensureFreshToken: () => Promise<string | null>
|
ensureFreshToken: () => Promise<string | null>
|
||||||
|
refreshGuilds: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextValue | null>(null)
|
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|||||||
203
src/index.css
203
src/index.css
@@ -64,6 +64,209 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(170, 59, 255, 0.08), transparent 220px) no-repeat var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
padding: 18px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-top-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 260px 1fr 220px;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 68vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-top-grid.hide-v1 {
|
||||||
|
grid-template-columns: 0 260px 1fr 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-top-grid.hide-v2 {
|
||||||
|
grid-template-columns: 220px 0 1fr 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-top-grid.hide-v4 {
|
||||||
|
grid-template-columns: 220px 260px 1fr 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-top-grid.hide-v1.hide-v2 {
|
||||||
|
grid-template-columns: 0 0 1fr 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-top-grid.hide-v1.hide-v4 {
|
||||||
|
grid-template-columns: 0 260px 1fr 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-top-grid.hide-v2.hide-v4 {
|
||||||
|
grid-template-columns: 220px 0 1fr 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-top-grid.hide-v1.hide-v2.hide-v4 {
|
||||||
|
grid-template-columns: 0 0 1fr 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-top-grid.hide-v1 .col-v1,
|
||||||
|
.chat-top-grid.hide-v2 .col-v2,
|
||||||
|
.chat-top-grid.hide-v4 .col-v4 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-right {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-history {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer .input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
width: min(560px, 92vw);
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--text-h);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-btn.active {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-wrap {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
min-width: 120px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: 2px solid var(--accent-border);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 1px solid var(--accent-border);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc2626;
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #dc2626;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-reset {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
background: color-mix(in srgb, var(--bg) 95%, var(--accent) 5%);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
|
|||||||
@@ -2,33 +2,16 @@ import { Link, Outlet } from 'react-router-dom'
|
|||||||
import { useAuth } from '../auth/auth-context'
|
import { useAuth } from '../auth/auth-context'
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const { isAuthed, session, logout } = useAuth()
|
const { isAuthed } = useAuth()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '220px 1fr', minHeight: '100vh' }}>
|
<main className="page-content">
|
||||||
<aside style={{ borderRight: '1px solid #ddd', padding: 16 }}>
|
{!isAuthed ? (
|
||||||
<h3>Fabric</h3>
|
<nav className="row-wrap" style={{ marginBottom: 12 }}>
|
||||||
<nav style={{ display: 'grid', gap: 8 }}>
|
<Link to="/login">Login</Link>
|
||||||
<Link to="/workspace">工作台</Link>
|
|
||||||
<Link to="/chat">聊天</Link>
|
|
||||||
<Link to="/login">登录</Link>
|
|
||||||
</nav>
|
</nav>
|
||||||
<div style={{ marginTop: 16, fontSize: 12 }}>
|
) : null}
|
||||||
{isAuthed ? (
|
|
||||||
<>
|
|
||||||
<div>{session?.user.email}</div>
|
|
||||||
<button onClick={() => logout()} style={{ marginTop: 6 }}>
|
|
||||||
登出
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span>未登录</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<main style={{ padding: 16 }}>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export type AuthSession = {
|
export type AuthSession = {
|
||||||
centerApiBase: string
|
centerApiBase: string
|
||||||
centerApiKey: string
|
|
||||||
accessToken: string
|
accessToken: string
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
tokenType: string
|
tokenType: string
|
||||||
|
expiresIn?: number
|
||||||
user: {
|
user: {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
@@ -18,6 +18,7 @@ export type AuthSession = {
|
|||||||
guildNodeId: string
|
guildNodeId: string
|
||||||
token: string
|
token: string
|
||||||
tokenType: string
|
tokenType: string
|
||||||
|
expiresIn?: number
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,22 +4,28 @@ import type { AuthSession } from './auth-storage'
|
|||||||
export type LoginPayload = { email: string; password: string }
|
export type LoginPayload = { email: string; password: string }
|
||||||
|
|
||||||
type LoginResponse = {
|
type LoginResponse = {
|
||||||
centerApiBase: string
|
|
||||||
accessToken: string
|
accessToken: string
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
tokenType: string
|
tokenType: string
|
||||||
|
expiresIn?: number
|
||||||
user: { id: string; email: string }
|
user: { id: string; email: string }
|
||||||
guilds: Array<{ nodeId: string; name: string; endpoint: string; status: 'active' | 'offline' | 'revoked' }>
|
guilds: Array<{ nodeId: string; name: string; endpoint: string; status: 'active' | 'offline' | 'revoked' }>
|
||||||
guildAccessTokens: Array<{ guildNodeId: string; token: string; tokenType: string }>
|
guildAccessTokens: Array<{ guildNodeId: string; token: string; tokenType: string; expiresIn?: number }>
|
||||||
}
|
}
|
||||||
|
|
||||||
type RefreshResponse = {
|
type RefreshResponse = {
|
||||||
accessToken: string
|
accessToken: string
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
tokenType: string
|
tokenType: string
|
||||||
|
expiresIn?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function centerClient(centerApiBase: string, centerApiKey: string) {
|
type MeGuildsResponse = {
|
||||||
|
guilds: Array<{ nodeId: string; name: string; endpoint: string; status: 'active' | 'offline' | 'revoked' }>
|
||||||
|
guildAccessTokens: Array<{ guildNodeId: string; token: string; tokenType: string; expiresIn?: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerClient(centerApiBase: string) {
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
baseURL: centerApiBase,
|
baseURL: centerApiBase,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
@@ -27,7 +33,6 @@ function centerClient(centerApiBase: string, centerApiKey: string) {
|
|||||||
|
|
||||||
client.interceptors.request.use((request) => {
|
client.interceptors.request.use((request) => {
|
||||||
const requestId = crypto.randomUUID()
|
const requestId = crypto.randomUUID()
|
||||||
request.headers['x-api-key'] = centerApiKey
|
|
||||||
request.headers['x-request-id'] = requestId
|
request.headers['x-request-id'] = requestId
|
||||||
request.headers['x-client-name'] = 'fabric-frontend'
|
request.headers['x-client-name'] = 'fabric-frontend'
|
||||||
return request
|
return request
|
||||||
@@ -36,16 +41,43 @@ function centerClient(centerApiBase: string, centerApiKey: string) {
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginCenter(centerApiBase: string, centerApiKey: string, payload: LoginPayload): Promise<AuthSession> {
|
export async function loginCenter(centerApiBase: string, payload: LoginPayload): Promise<AuthSession> {
|
||||||
const res = await centerClient(centerApiBase, centerApiKey).post<LoginResponse>('/auth/login', payload)
|
const res = await centerClient(centerApiBase).post<LoginResponse>('/auth/login', payload)
|
||||||
return { ...res.data, centerApiBase, centerApiKey }
|
return { ...res.data, centerApiBase }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshCenter(centerApiBase: string, centerApiKey: string, refreshToken: string): Promise<RefreshResponse> {
|
export async function refreshCenter(centerApiBase: string, refreshToken: string): Promise<RefreshResponse> {
|
||||||
const res = await centerClient(centerApiBase, centerApiKey).post<RefreshResponse>('/auth/refresh', { refreshToken })
|
const res = await centerClient(centerApiBase).post<RefreshResponse>('/auth/refresh', { refreshToken })
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logoutCenter(centerApiBase: string, centerApiKey: string, refreshToken: string): Promise<void> {
|
export async function logoutCenter(centerApiBase: string, refreshToken: string): Promise<void> {
|
||||||
await centerClient(centerApiBase, centerApiKey).post('/auth/logout', { refreshToken })
|
await centerClient(centerApiBase).post('/auth/logout', { refreshToken })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function meGuildsCenter(centerApiBase: string, accessToken: string): Promise<MeGuildsResponse> {
|
||||||
|
const res = await centerClient(centerApiBase).get<MeGuildsResponse>('/auth/me/guilds', {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinGuildCenter(centerApiBase: string, accessToken: string, guildNodeId: string): Promise<void> {
|
||||||
|
await centerClient(centerApiBase).post(
|
||||||
|
'/auth/me/guilds/join',
|
||||||
|
{ guildNodeId },
|
||||||
|
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function guildMembersCenter(
|
||||||
|
centerApiBase: string,
|
||||||
|
accessToken: string,
|
||||||
|
guildNodeId: string,
|
||||||
|
): Promise<Array<{ userId: string; email: string; status: string }>> {
|
||||||
|
const res = await centerClient(centerApiBase).get<Array<{ userId: string; email: string; status: string }>>(
|
||||||
|
`/auth/guilds/${encodeURIComponent(guildNodeId)}/members`,
|
||||||
|
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||||
|
)
|
||||||
|
return Array.isArray(res.data) ? res.data : []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { io, type Socket } from 'socket.io-client'
|
import { io, type Socket } from 'socket.io-client'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
|
||||||
import { useAuth } from '../auth/auth-context'
|
import { useAuth } from '../auth/auth-context'
|
||||||
|
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
|
||||||
|
|
||||||
type MessageItem = {
|
type MessageItem = {
|
||||||
messageId: string
|
messageId: string
|
||||||
@@ -11,35 +11,38 @@ type MessageItem = {
|
|||||||
isDeleted?: boolean
|
isDeleted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type PageInfo = {
|
type GuildChannel = { id: string; name: string; guildId?: string }
|
||||||
nextExpectedSeq?: number
|
type MemberItem = { userId: string; email: string; status: string }
|
||||||
highestCommittedSeq?: number
|
|
||||||
hasMore?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const { session } = useAuth()
|
const { session, logout, ensureFreshToken, refreshGuilds } = useAuth()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [selectedGuildId, setSelectedGuildId] = useState('')
|
||||||
const guildId = searchParams.get('guildId') ?? ''
|
const [selectedChannelId, setSelectedChannelId] = useState('')
|
||||||
const [channelId, setChannelId] = useState(() => searchParams.get('channelId') ?? '')
|
const [channels, setChannels] = useState<GuildChannel[]>([])
|
||||||
const [content, setContent] = useState('')
|
const [guildDbId, setGuildDbId] = useState('')
|
||||||
|
const [members, setMembers] = useState<MemberItem[]>([])
|
||||||
const [messages, setMessages] = useState<MessageItem[]>([])
|
const [messages, setMessages] = useState<MessageItem[]>([])
|
||||||
const [pageInfo, setPageInfo] = useState<PageInfo | null>(null)
|
const [content, setContent] = useState('')
|
||||||
const [socketState, setSocketState] = useState<'offline' | 'online'>('offline')
|
const [newChannelName, setNewChannelName] = useState('')
|
||||||
const [onlineCount, setOnlineCount] = useState(0)
|
const [joinGuildNodeId, setJoinGuildNodeId] = useState('')
|
||||||
const [typingUsers, setTypingUsers] = useState<string[]>([])
|
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([])
|
||||||
|
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false)
|
||||||
|
const [showGuilds, setShowGuilds] = useState(true)
|
||||||
|
const [showChannels, setShowChannels] = useState(true)
|
||||||
|
const [showMembers, setShowMembers] = useState(true)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [seqFrom, setSeqFrom] = useState('1')
|
|
||||||
const [seqTo, setSeqTo] = useState('999999')
|
|
||||||
const [limit, setLimit] = useState('50')
|
|
||||||
const [editingMessageId, setEditingMessageId] = useState('')
|
|
||||||
const [editingContent, setEditingContent] = useState('')
|
|
||||||
|
|
||||||
const guild = useMemo(() => (session?.guilds ?? []).find((g) => g.nodeId === guildId) ?? null, [session, guildId])
|
const guilds = session?.guilds ?? []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedGuildId && guilds.length) setSelectedGuildId(guilds[0].nodeId)
|
||||||
|
}, [guilds, selectedGuildId])
|
||||||
|
|
||||||
|
const guild = useMemo(() => guilds.find((g) => g.nodeId === selectedGuildId) ?? null, [guilds, selectedGuildId])
|
||||||
const guildToken = useMemo(
|
const guildToken = useMemo(
|
||||||
() => (session?.guildAccessTokens ?? []).find((x) => x.guildNodeId === guildId)?.token ?? '',
|
() => (session?.guildAccessTokens ?? []).find((x) => x.guildNodeId === selectedGuildId)?.token ?? '',
|
||||||
[session, guildId],
|
[session, selectedGuildId],
|
||||||
)
|
)
|
||||||
|
|
||||||
function guildApi() {
|
function guildApi() {
|
||||||
@@ -47,9 +50,7 @@ export default function ChatPage() {
|
|||||||
return axios.create({
|
return axios.create({
|
||||||
baseURL: `${guild.endpoint}/api`,
|
baseURL: `${guild.endpoint}/api`,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
headers: {
|
headers: { Authorization: `Bearer ${guildToken}` },
|
||||||
Authorization: `Bearer ${guildToken}`,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,190 +58,245 @@ export default function ChatPage() {
|
|||||||
if (!guild || !guildToken) return null
|
if (!guild || !guildToken) return null
|
||||||
return io(`${guild.endpoint}/realtime`, {
|
return io(`${guild.endpoint}/realtime`, {
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
auth: {
|
auth: { token: guildToken },
|
||||||
token: guildToken,
|
|
||||||
},
|
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
})
|
})
|
||||||
}, [guild, guildToken])
|
}, [guild, guildToken])
|
||||||
|
|
||||||
useEffect(() => {
|
async function loadChannels() {
|
||||||
if (!socket) return
|
if (!guild || !guildToken) return
|
||||||
|
setError('')
|
||||||
socket.on('connect', () => {
|
try {
|
||||||
setSocketState('online')
|
const res = await guildApi().get('/channels', { params: guildDbId ? { guildId: guildDbId } : undefined })
|
||||||
if (!channelId) return
|
const list = Array.isArray(res.data) ? (res.data as GuildChannel[]) : []
|
||||||
socket.emit('join_channel', { channelId })
|
setChannels(list)
|
||||||
void guildApi()
|
if (!guildDbId && list[0]?.guildId) setGuildDbId(list[0].guildId)
|
||||||
.get(`/channels/${channelId}/messages`, {
|
if (!selectedChannelId && list.length) setSelectedChannelId(list[0].id)
|
||||||
params: {
|
} catch {
|
||||||
seq_from: Number(seqFrom || '1'),
|
setError('Failed to load channels')
|
||||||
seq_to: Number(seqTo || '999999'),
|
setChannels([])
|
||||||
limit: Number(limit || '50'),
|
}
|
||||||
},
|
}
|
||||||
})
|
|
||||||
.then((res) => {
|
async function loadMembers() {
|
||||||
setMessages(res.data.items ?? [])
|
if (!session || !selectedGuildId) return
|
||||||
setPageInfo(res.data.page ?? null)
|
setError('')
|
||||||
})
|
try {
|
||||||
})
|
const token = await ensureFreshToken()
|
||||||
socket.on('disconnect', () => setSocketState('offline'))
|
if (!token) return
|
||||||
socket.on('presence.online', (e: { onlineCount?: number }) => setOnlineCount(e.onlineCount ?? 0))
|
const list = await guildMembersCenter(session.centerApiBase, token, selectedGuildId)
|
||||||
socket.on('presence.offline', (e: { onlineCount?: number }) => setOnlineCount(e.onlineCount ?? 0))
|
setMembers(list)
|
||||||
socket.on('typing.start', (e: { channelId?: string; userId?: string }) => {
|
} catch {
|
||||||
if (!e.channelId || e.channelId !== channelId || !e.userId || e.userId === session?.user.id) return
|
setError('Failed to load guild members')
|
||||||
setTypingUsers((prev) => (prev.includes(e.userId as string) ? prev : [...prev, e.userId as string]))
|
setMembers([])
|
||||||
})
|
}
|
||||||
socket.on('typing.stop', (e: { channelId?: string; userId?: string }) => {
|
|
||||||
if (!e.channelId || e.channelId !== channelId || !e.userId) return
|
|
||||||
setTypingUsers((prev) => prev.filter((x) => x !== e.userId))
|
|
||||||
})
|
|
||||||
socket.on('message.created', (m: MessageItem) => setMessages((prev) => [...prev, m]))
|
|
||||||
socket.on('message.updated', (m: MessageItem) =>
|
|
||||||
setMessages((prev) => prev.map((x) => (x.messageId === m.messageId ? m : x))),
|
|
||||||
)
|
|
||||||
socket.on('message.deleted', (m: MessageItem) =>
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((x) => (x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
socket.connect()
|
|
||||||
return () => {
|
|
||||||
socket.removeAllListeners()
|
|
||||||
socket.disconnect()
|
|
||||||
}
|
}
|
||||||
}, [socket, channelId, seqFrom, seqTo, limit, guild, guildToken, session?.user.id])
|
|
||||||
|
|
||||||
async function pullMessages() {
|
async function pullMessages() {
|
||||||
if (!channelId || !guild || !guildToken) return
|
if (!selectedChannelId || !guild || !guildToken) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const res = await guildApi().get(`/channels/${channelId}/messages`, {
|
const res = await guildApi().get(`/channels/${selectedChannelId}/messages`, {
|
||||||
params: {
|
params: { seq_from: 1, seq_to: 999999, limit: 100 },
|
||||||
seq_from: Number(seqFrom || '1'),
|
|
||||||
seq_to: Number(seqTo || '999999'),
|
|
||||||
limit: Number(limit || '50'),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
setMessages(res.data.items ?? [])
|
setMessages(res.data.items ?? [])
|
||||||
setPageInfo(res.data.page ?? null)
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('消息拉取失败')
|
setError('Failed to load messages')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
if (!channelId || !content.trim() || !guild || !guildToken) return
|
if (!selectedChannelId || !content.trim() || !guild || !guildToken) return
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
await guildApi().post(`/channels/${channelId}/messages`, {
|
await guildApi().post(`/channels/${selectedChannelId}/messages`, {
|
||||||
content,
|
content,
|
||||||
authorUserId: session?.user.id ?? 'unknown',
|
authorUserId: session?.user.id ?? 'unknown',
|
||||||
})
|
})
|
||||||
socket?.emit('typing.stop', { channelId })
|
|
||||||
setContent('')
|
setContent('')
|
||||||
} catch {
|
|
||||||
setError('发送失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function editMessage() {
|
|
||||||
if (!channelId || !editingMessageId || !editingContent.trim() || !guild || !guildToken) return
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
await guildApi().patch(`/channels/${channelId}/messages/${editingMessageId}`, {
|
|
||||||
content: editingContent,
|
|
||||||
})
|
|
||||||
setEditingContent('')
|
|
||||||
await pullMessages()
|
await pullMessages()
|
||||||
} catch {
|
} catch {
|
||||||
setError('编辑失败')
|
setError('Failed to send message')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteMessage(messageId: string) {
|
async function addGuild() {
|
||||||
if (!channelId || !messageId || !guild || !guildToken) return
|
if (!session || !joinGuildNodeId.trim()) return
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
await guildApi().delete(`/channels/${channelId}/messages/${messageId}`)
|
const token = await ensureFreshToken()
|
||||||
await pullMessages()
|
if (!token) return
|
||||||
|
await joinGuildCenter(session.centerApiBase, token, joinGuildNodeId.trim())
|
||||||
|
await refreshGuilds()
|
||||||
|
setJoinGuildNodeId('')
|
||||||
} catch {
|
} catch {
|
||||||
setError('删除失败')
|
setError('Failed to add guild')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onChangeChannel(value: string) {
|
function toggleMember(userId: string) {
|
||||||
setChannelId(value)
|
setSelectedMemberIds((prev) => (prev.includes(userId) ? prev.filter((x) => x !== userId) : [...prev, userId]))
|
||||||
const next = new URLSearchParams(searchParams)
|
|
||||||
if (guildId) next.set('guildId', guildId)
|
|
||||||
if (value) next.set('channelId', value)
|
|
||||||
else next.delete('channelId')
|
|
||||||
setSearchParams(next)
|
|
||||||
setTypingUsers([])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTypingChange(value: string) {
|
async function createChannel() {
|
||||||
const wasEmpty = !content.trim()
|
if (!guild || !guildToken || !newChannelName.trim()) return
|
||||||
const nowEmpty = !value.trim()
|
if (!guildDbId) {
|
||||||
setContent(value)
|
setError('Cannot create channel: guildId is missing')
|
||||||
if (!channelId || !socket) return
|
return
|
||||||
if (wasEmpty && !nowEmpty) socket.emit('typing.start', { channelId })
|
}
|
||||||
if (!wasEmpty && nowEmpty) socket.emit('typing.stop', { channelId })
|
setError('')
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: newChannelName.trim(),
|
||||||
|
guildId: guildDbId,
|
||||||
|
memberUserIds: selectedMemberIds,
|
||||||
|
}
|
||||||
|
const res = await guildApi().post('/channels', payload)
|
||||||
|
const createdId = res.data?.id as string | undefined
|
||||||
|
setNewChannelName('')
|
||||||
|
setSelectedMemberIds([])
|
||||||
|
setShowCreateChannelModal(false)
|
||||||
|
await loadChannels()
|
||||||
|
if (createdId) setSelectedChannelId(createdId)
|
||||||
|
} catch {
|
||||||
|
setError('Failed to create channel')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!channelId || !socket || !socket.connected) return
|
void loadMembers()
|
||||||
socket.emit('join_channel', { channelId })
|
setSelectedMemberIds([])
|
||||||
|
}, [selectedGuildId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadChannels()
|
||||||
|
setMessages([])
|
||||||
|
setSelectedChannelId('')
|
||||||
|
}, [selectedGuildId, guildDbId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void pullMessages()
|
||||||
|
}, [selectedChannelId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket || !selectedChannelId) return
|
||||||
|
|
||||||
|
socket.on('message.created', (m: MessageItem) => setMessages((prev) => [...prev, m]))
|
||||||
|
socket.on('message.updated', (m: MessageItem) =>
|
||||||
|
setMessages((prev) => prev.map((x) => (x.messageId === m.messageId ? m : x))),
|
||||||
|
)
|
||||||
|
socket.on('message.deleted', (m: MessageItem) =>
|
||||||
|
setMessages((prev) => prev.map((x) => (x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x))),
|
||||||
|
)
|
||||||
|
|
||||||
|
socket.connect()
|
||||||
|
socket.emit('join_channel', { channelId: selectedChannelId })
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.emit('leave_channel', { channelId })
|
socket.emit('leave_channel', { channelId: selectedChannelId })
|
||||||
|
socket.removeAllListeners()
|
||||||
|
socket.disconnect()
|
||||||
}
|
}
|
||||||
}, [channelId, socket, socketState])
|
}, [socket, selectedChannelId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section className="chat-layout">
|
||||||
<h2>聊天</h2>
|
<div className={`chat-top-grid ${!showGuilds ? 'hide-v1' : ''} ${!showChannels ? 'hide-v2' : ''} ${!showMembers ? 'hide-v4' : ''}`}>
|
||||||
<p>Guild: {guildId || '-'}</p>
|
<div className="panel col-list col-v1">
|
||||||
<p>Socket: {socketState}</p>
|
<h3>Guilds</h3>
|
||||||
<p>在线人数: {onlineCount}</p>
|
<div className="row-wrap" style={{ marginBottom: 8 }}>
|
||||||
{loading ? <p>加载中...</p> : null}
|
<input className="input" value={joinGuildNodeId} onChange={(e) => setJoinGuildNodeId(e.target.value)} placeholder="Guild nodeId" />
|
||||||
{error ? <p style={{ color: 'crimson' }}>{error}</p> : null}
|
<button className="btn btn-secondary" onClick={addGuild}>Add guild</button>
|
||||||
{typingUsers.length ? <p>正在输入: {typingUsers.join(', ')}</p> : null}
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
|
||||||
<input value={channelId} onChange={(e) => onChangeChannel(e.target.value)} placeholder="Channel ID" />
|
|
||||||
<input value={seqFrom} onChange={(e) => setSeqFrom(e.target.value)} placeholder="seq_from" />
|
|
||||||
<input value={seqTo} onChange={(e) => setSeqTo(e.target.value)} placeholder="seq_to" />
|
|
||||||
<input value={limit} onChange={(e) => setLimit(e.target.value)} placeholder="limit" />
|
|
||||||
<button onClick={pullMessages}>拉取</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
<ul className="list-reset">
|
||||||
<input value={content} onChange={(e) => onTypingChange(e.target.value)} placeholder="输入消息" />
|
{guilds.map((g) => (
|
||||||
<button onClick={sendMessage}>发送</button>
|
<li key={g.nodeId}>
|
||||||
</div>
|
<button className={`list-btn ${selectedGuildId === g.nodeId ? 'active' : ''}`} onClick={() => setSelectedGuildId(g.nodeId)}>
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
{g.name}
|
||||||
<input value={editingMessageId} onChange={(e) => setEditingMessageId(e.target.value)} placeholder="messageId" />
|
|
||||||
<input value={editingContent} onChange={(e) => setEditingContent(e.target.value)} placeholder="新内容" />
|
|
||||||
<button onClick={editMessage}>编辑</button>
|
|
||||||
</div>
|
|
||||||
<ul>
|
|
||||||
{messages.map((m) => (
|
|
||||||
<li key={m.messageId}>
|
|
||||||
#{m.seq} {m.content} ({m.messageId}){' '}
|
|
||||||
<button type="button" onClick={() => deleteMessage(m.messageId)}>
|
|
||||||
删除
|
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{!loading && !messages.length ? <p>暂无消息</p> : null}
|
</div>
|
||||||
{pageInfo ? (
|
|
||||||
<p>
|
<div className="panel col-list col-v2">
|
||||||
next_expected_seq: {pageInfo.nextExpectedSeq ?? '-'} | highest_committed_seq: {pageInfo.highestCommittedSeq ?? '-'} |
|
<h3>Channels</h3>
|
||||||
has_more: {String(pageInfo.hasMore ?? false)}
|
<div className="row-wrap" style={{ marginBottom: 8 }}>
|
||||||
</p>
|
<button className="btn btn-secondary" onClick={() => setShowCreateChannelModal(true)}>Create channel</button>
|
||||||
|
</div>
|
||||||
|
<ul className="list-reset">
|
||||||
|
{channels.map((c) => (
|
||||||
|
<li key={c.id}>
|
||||||
|
<button className={`list-btn ${selectedChannelId === c.id ? 'active' : ''}`} onClick={() => setSelectedChannelId(c.id)}>
|
||||||
|
#{c.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-right col-v3">
|
||||||
|
<div className="panel chat-history">
|
||||||
|
<h3>Messages</h3>
|
||||||
|
{loading ? <p className="muted">Loading...</p> : null}
|
||||||
|
{error ? <p className="error-text">{error}</p> : null}
|
||||||
|
<ul className="list-reset">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<li key={m.messageId} className="card" style={{ marginTop: 8 }}>
|
||||||
|
<div>
|
||||||
|
<strong>#{m.seq}</strong> {m.content}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="panel composer">
|
||||||
|
<input className="input" value={content} onChange={(e) => setContent(e.target.value)} placeholder="Type a message" />
|
||||||
|
<button className="btn" onClick={sendMessage}>Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel col-list col-v4">
|
||||||
|
<h3>Members</h3>
|
||||||
|
<ul className="list-reset">
|
||||||
|
{members.map((m) => (
|
||||||
|
<li key={m.userId}><span className="muted">{m.email}</span></li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel footer-actions">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowGuilds((v) => !v)}>{showGuilds ? 'Hide v1' : 'Show v1'}</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowChannels((v) => !v)}>{showChannels ? 'Hide v2' : 'Show v2'}</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowMembers((v) => !v)}>{showMembers ? 'Hide v4' : 'Show v4'}</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => void logout()}>Logout</button>
|
||||||
|
<button className="btn btn-secondary" type="button">Settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreateChannelModal ? (
|
||||||
|
<div className="modal-backdrop" onClick={() => setShowCreateChannelModal(false)}>
|
||||||
|
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Create channel</h3>
|
||||||
|
<input className="input" value={newChannelName} onChange={(e) => setNewChannelName(e.target.value)} placeholder="Channel name" />
|
||||||
|
<p className="muted" style={{ marginTop: 8 }}>Select members to include</p>
|
||||||
|
<div className="modal-list">
|
||||||
|
{members.map((m) => (
|
||||||
|
<label key={m.userId} className="row-wrap" style={{ alignItems: 'center' }}>
|
||||||
|
<input type="checkbox" checked={selectedMemberIds.includes(m.userId)} onChange={() => toggleMember(m.userId)} />
|
||||||
|
<span>{m.email}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="row-wrap" style={{ marginTop: 12 }}>
|
||||||
|
<button className="btn" onClick={createChannel}>Create</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowCreateChannelModal(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export default function LoginPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { login, isAuthed, session } = useAuth()
|
const { login, isAuthed, session } = useAuth()
|
||||||
const [centerApiBase, setCenterApiBase] = useState('http://localhost:7001/api')
|
const [centerApiBase, setCenterApiBase] = useState('http://localhost:7001/api')
|
||||||
const [centerApiKey, setCenterApiKey] = useState('')
|
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -16,38 +15,35 @@ export default function LoginPage() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
await login(centerApiBase.trim(), centerApiKey.trim(), email, password)
|
await login(centerApiBase.trim(), email, password)
|
||||||
navigate('/workspace')
|
navigate('/workspace')
|
||||||
} catch {
|
} catch {
|
||||||
setError('登录失败,请检查账号密码')
|
setError('Login failed. Please check your email and password.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section className="panel">
|
||||||
<h2>登录</h2>
|
<h2>Login</h2>
|
||||||
{isAuthed ? <p>当前用户:{session?.user.email}</p> : null}
|
{isAuthed ? <p className="muted">Current user: {session?.user.email}</p> : null}
|
||||||
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 8, maxWidth: 420 }}>
|
<form onSubmit={onSubmit} className="form-grid" style={{ maxWidth: 460 }}>
|
||||||
<input
|
<input
|
||||||
|
className="input"
|
||||||
value={centerApiBase}
|
value={centerApiBase}
|
||||||
onChange={(e) => setCenterApiBase(e.target.value)}
|
onChange={(e) => setCenterApiBase(e.target.value)}
|
||||||
placeholder="Center API Base (e.g. http://localhost:7001/api)"
|
placeholder="Center API Base (e.g. http://localhost:7001/api)"
|
||||||
/>
|
/>
|
||||||
|
<input className="input" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" type="email" />
|
||||||
<input
|
<input
|
||||||
value={centerApiKey}
|
className="input"
|
||||||
onChange={(e) => setCenterApiKey(e.target.value)}
|
|
||||||
placeholder="Center API Key"
|
|
||||||
/>
|
|
||||||
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" type="email" />
|
|
||||||
<input
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
type="password"
|
type="password"
|
||||||
/>
|
/>
|
||||||
<button type="submit">登录</button>
|
<button className="btn" type="submit">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
<p>{error}</p>
|
{error ? <p className="error-text">{error}</p> : null}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function WorkspacePage() {
|
|||||||
const res = await axios.get(`${session.centerApiBase}/healthz`)
|
const res = await axios.get(`${session.centerApiBase}/healthz`)
|
||||||
setHealth(JSON.stringify(res.data))
|
setHealth(JSON.stringify(res.data))
|
||||||
} catch {
|
} catch {
|
||||||
setHealth('center healthz 访问失败')
|
setHealth('Failed to access center healthz')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,26 +45,29 @@ export default function WorkspacePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section className="panel">
|
||||||
<h2>工作台</h2>
|
<h2>Workspace</h2>
|
||||||
<p>Center: {session?.centerApiBase || '-'}</p>
|
<p className="muted">Center: {session?.centerApiBase || '-'}</p>
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<button type="button" onClick={checkCenterHealth}>
|
<button className="btn" type="button" onClick={checkCenterHealth}>
|
||||||
测试 Center healthz
|
Test Center healthz
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p>{health}</p>
|
{health ? <p className="muted">{health}</p> : null}
|
||||||
|
|
||||||
<ul>
|
<ul className="list-reset">
|
||||||
{(session?.guilds ?? []).map((g) => (
|
{(session?.guilds ?? []).map((g) => (
|
||||||
<li key={g.nodeId} style={{ marginTop: 8 }}>
|
<li key={g.nodeId} className="card" style={{ marginTop: 12 }}>
|
||||||
<strong>{g.name}</strong> ({g.nodeId}) - {g.endpoint}{' '}
|
<div>
|
||||||
<button type="button" onClick={() => loadChannels(g.nodeId, g.endpoint)}>
|
<strong>{g.name}</strong> <span className="muted">({g.nodeId})</span>
|
||||||
加载频道
|
</div>
|
||||||
|
<div className="muted">{g.endpoint}</div>
|
||||||
|
<button className="btn btn-secondary" type="button" onClick={() => loadChannels(g.nodeId, g.endpoint)}>
|
||||||
|
Load channels
|
||||||
</button>
|
</button>
|
||||||
<ul>
|
<ul className="list-reset" style={{ marginTop: 10 }}>
|
||||||
{(channelsByGuild[g.nodeId] ?? []).map((c) => (
|
{(channelsByGuild[g.nodeId] ?? []).map((c) => (
|
||||||
<li key={c.id}>
|
<li key={c.id} style={{ marginTop: 6 }}>
|
||||||
<Link
|
<Link
|
||||||
to={`/chat?guildId=${encodeURIComponent(g.nodeId)}&channelId=${encodeURIComponent(c.id)}`}
|
to={`/chat?guildId=${encodeURIComponent(g.nodeId)}&channelId=${encodeURIComponent(c.id)}`}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user