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 ChatPage from './pages/ChatPage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import WorkspacePage from './pages/WorkspacePage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -14,7 +13,7 @@ export default function App() {
|
||||
path="workspace"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<WorkspacePage />
|
||||
<ChatPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { clearAuthSession, getAuthSession, isAccessTokenStale, setAuthSession } 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 type { AuthContextValue } from './auth-context'
|
||||
|
||||
@@ -13,15 +13,15 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
() => ({
|
||||
session,
|
||||
isAuthed: !!session,
|
||||
login: async (centerApiBase: string, centerApiKey: string, email: string, password: string) => {
|
||||
const next = await loginCenter(centerApiBase, centerApiKey, { email, password })
|
||||
login: async (centerApiBase: string, email: string, password: string) => {
|
||||
const next = await loginCenter(centerApiBase, { email, password })
|
||||
setAuthSession(next)
|
||||
setSession(next)
|
||||
},
|
||||
logout: async () => {
|
||||
if (session?.refreshToken) {
|
||||
try {
|
||||
await logoutCenter(session.centerApiBase, session.centerApiKey, session.refreshToken)
|
||||
await logoutCenter(session.centerApiBase, session.refreshToken)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
@@ -33,17 +33,45 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
if (!session) return null
|
||||
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 = {
|
||||
...session,
|
||||
accessToken: refreshed.accessToken,
|
||||
refreshToken: refreshed.refreshToken,
|
||||
tokenType: refreshed.tokenType,
|
||||
expiresIn: refreshed.expiresIn,
|
||||
}
|
||||
setAuthSession(next)
|
||||
setSession(next)
|
||||
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],
|
||||
)
|
||||
|
||||
@@ -4,9 +4,10 @@ import type { AuthSession } from '../lib/auth-storage'
|
||||
export type AuthContextValue = {
|
||||
session: AuthSession | null
|
||||
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>
|
||||
ensureFreshToken: () => Promise<string | null>
|
||||
refreshGuilds: () => Promise<void>
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
203
src/index.css
203
src/index.css
@@ -64,6 +64,209 @@
|
||||
|
||||
body {
|
||||
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,
|
||||
|
||||
@@ -2,33 +2,16 @@ import { Link, Outlet } from 'react-router-dom'
|
||||
import { useAuth } from '../auth/auth-context'
|
||||
|
||||
export default function AppLayout() {
|
||||
const { isAuthed, session, logout } = useAuth()
|
||||
const { isAuthed } = useAuth()
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '220px 1fr', minHeight: '100vh' }}>
|
||||
<aside style={{ borderRight: '1px solid #ddd', padding: 16 }}>
|
||||
<h3>Fabric</h3>
|
||||
<nav style={{ display: 'grid', gap: 8 }}>
|
||||
<Link to="/workspace">工作台</Link>
|
||||
<Link to="/chat">聊天</Link>
|
||||
<Link to="/login">登录</Link>
|
||||
<main className="page-content">
|
||||
{!isAuthed ? (
|
||||
<nav className="row-wrap" style={{ marginBottom: 12 }}>
|
||||
<Link to="/login">Login</Link>
|
||||
</nav>
|
||||
<div style={{ marginTop: 16, fontSize: 12 }}>
|
||||
{isAuthed ? (
|
||||
<>
|
||||
<div>{session?.user.email}</div>
|
||||
<button onClick={() => logout()} style={{ marginTop: 6 }}>
|
||||
登出
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span>未登录</span>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<main style={{ padding: 16 }}>
|
||||
) : null}
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export type AuthSession = {
|
||||
centerApiBase: string
|
||||
centerApiKey: string
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
tokenType: string
|
||||
expiresIn?: number
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
@@ -18,6 +18,7 @@ export type AuthSession = {
|
||||
guildNodeId: string
|
||||
token: string
|
||||
tokenType: string
|
||||
expiresIn?: number
|
||||
}>
|
||||
}
|
||||
|
||||
|
||||
@@ -4,22 +4,28 @@ import type { AuthSession } from './auth-storage'
|
||||
export type LoginPayload = { email: string; password: string }
|
||||
|
||||
type LoginResponse = {
|
||||
centerApiBase: string
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
tokenType: string
|
||||
expiresIn?: number
|
||||
user: { id: string; email: string }
|
||||
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 = {
|
||||
accessToken: string
|
||||
refreshToken: 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({
|
||||
baseURL: centerApiBase,
|
||||
timeout: 10000,
|
||||
@@ -27,7 +33,6 @@ function centerClient(centerApiBase: string, centerApiKey: string) {
|
||||
|
||||
client.interceptors.request.use((request) => {
|
||||
const requestId = crypto.randomUUID()
|
||||
request.headers['x-api-key'] = centerApiKey
|
||||
request.headers['x-request-id'] = requestId
|
||||
request.headers['x-client-name'] = 'fabric-frontend'
|
||||
return request
|
||||
@@ -36,16 +41,43 @@ function centerClient(centerApiBase: string, centerApiKey: string) {
|
||||
return client
|
||||
}
|
||||
|
||||
export async function loginCenter(centerApiBase: string, centerApiKey: string, payload: LoginPayload): Promise<AuthSession> {
|
||||
const res = await centerClient(centerApiBase, centerApiKey).post<LoginResponse>('/auth/login', payload)
|
||||
return { ...res.data, centerApiBase, centerApiKey }
|
||||
export async function loginCenter(centerApiBase: string, payload: LoginPayload): Promise<AuthSession> {
|
||||
const res = await centerClient(centerApiBase).post<LoginResponse>('/auth/login', payload)
|
||||
return { ...res.data, centerApiBase }
|
||||
}
|
||||
|
||||
export async function refreshCenter(centerApiBase: string, centerApiKey: string, refreshToken: string): Promise<RefreshResponse> {
|
||||
const res = await centerClient(centerApiBase, centerApiKey).post<RefreshResponse>('/auth/refresh', { refreshToken })
|
||||
export async function refreshCenter(centerApiBase: string, refreshToken: string): Promise<RefreshResponse> {
|
||||
const res = await centerClient(centerApiBase).post<RefreshResponse>('/auth/refresh', { refreshToken })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function logoutCenter(centerApiBase: string, centerApiKey: string, refreshToken: string): Promise<void> {
|
||||
await centerClient(centerApiBase, centerApiKey).post('/auth/logout', { refreshToken })
|
||||
export async function logoutCenter(centerApiBase: string, refreshToken: string): Promise<void> {
|
||||
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 { io, type Socket } from 'socket.io-client'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useAuth } from '../auth/auth-context'
|
||||
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
|
||||
|
||||
type MessageItem = {
|
||||
messageId: string
|
||||
@@ -11,35 +11,38 @@ type MessageItem = {
|
||||
isDeleted?: boolean
|
||||
}
|
||||
|
||||
type PageInfo = {
|
||||
nextExpectedSeq?: number
|
||||
highestCommittedSeq?: number
|
||||
hasMore?: boolean
|
||||
}
|
||||
type GuildChannel = { id: string; name: string; guildId?: string }
|
||||
type MemberItem = { userId: string; email: string; status: string }
|
||||
|
||||
export default function ChatPage() {
|
||||
const { session } = useAuth()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const guildId = searchParams.get('guildId') ?? ''
|
||||
const [channelId, setChannelId] = useState(() => searchParams.get('channelId') ?? '')
|
||||
const [content, setContent] = useState('')
|
||||
const { session, logout, ensureFreshToken, refreshGuilds } = useAuth()
|
||||
const [selectedGuildId, setSelectedGuildId] = useState('')
|
||||
const [selectedChannelId, setSelectedChannelId] = useState('')
|
||||
const [channels, setChannels] = useState<GuildChannel[]>([])
|
||||
const [guildDbId, setGuildDbId] = useState('')
|
||||
const [members, setMembers] = useState<MemberItem[]>([])
|
||||
const [messages, setMessages] = useState<MessageItem[]>([])
|
||||
const [pageInfo, setPageInfo] = useState<PageInfo | null>(null)
|
||||
const [socketState, setSocketState] = useState<'offline' | 'online'>('offline')
|
||||
const [onlineCount, setOnlineCount] = useState(0)
|
||||
const [typingUsers, setTypingUsers] = useState<string[]>([])
|
||||
const [content, setContent] = useState('')
|
||||
const [newChannelName, setNewChannelName] = useState('')
|
||||
const [joinGuildNodeId, setJoinGuildNodeId] = useState('')
|
||||
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 [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(
|
||||
() => (session?.guildAccessTokens ?? []).find((x) => x.guildNodeId === guildId)?.token ?? '',
|
||||
[session, guildId],
|
||||
() => (session?.guildAccessTokens ?? []).find((x) => x.guildNodeId === selectedGuildId)?.token ?? '',
|
||||
[session, selectedGuildId],
|
||||
)
|
||||
|
||||
function guildApi() {
|
||||
@@ -47,9 +50,7 @@ export default function ChatPage() {
|
||||
return axios.create({
|
||||
baseURL: `${guild.endpoint}/api`,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
Authorization: `Bearer ${guildToken}`,
|
||||
},
|
||||
headers: { Authorization: `Bearer ${guildToken}` },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,190 +58,245 @@ export default function ChatPage() {
|
||||
if (!guild || !guildToken) return null
|
||||
return io(`${guild.endpoint}/realtime`, {
|
||||
transports: ['websocket'],
|
||||
auth: {
|
||||
token: guildToken,
|
||||
},
|
||||
auth: { token: guildToken },
|
||||
autoConnect: false,
|
||||
})
|
||||
}, [guild, guildToken])
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return
|
||||
|
||||
socket.on('connect', () => {
|
||||
setSocketState('online')
|
||||
if (!channelId) return
|
||||
socket.emit('join_channel', { channelId })
|
||||
void guildApi()
|
||||
.get(`/channels/${channelId}/messages`, {
|
||||
params: {
|
||||
seq_from: Number(seqFrom || '1'),
|
||||
seq_to: Number(seqTo || '999999'),
|
||||
limit: Number(limit || '50'),
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
setMessages(res.data.items ?? [])
|
||||
setPageInfo(res.data.page ?? null)
|
||||
})
|
||||
})
|
||||
socket.on('disconnect', () => setSocketState('offline'))
|
||||
socket.on('presence.online', (e: { onlineCount?: number }) => setOnlineCount(e.onlineCount ?? 0))
|
||||
socket.on('presence.offline', (e: { onlineCount?: number }) => setOnlineCount(e.onlineCount ?? 0))
|
||||
socket.on('typing.start', (e: { channelId?: string; userId?: string }) => {
|
||||
if (!e.channelId || e.channelId !== channelId || !e.userId || e.userId === session?.user.id) return
|
||||
setTypingUsers((prev) => (prev.includes(e.userId as string) ? prev : [...prev, e.userId as string]))
|
||||
})
|
||||
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()
|
||||
async function loadChannels() {
|
||||
if (!guild || !guildToken) return
|
||||
setError('')
|
||||
try {
|
||||
const res = await guildApi().get('/channels', { params: guildDbId ? { guildId: guildDbId } : undefined })
|
||||
const list = Array.isArray(res.data) ? (res.data as GuildChannel[]) : []
|
||||
setChannels(list)
|
||||
if (!guildDbId && list[0]?.guildId) setGuildDbId(list[0].guildId)
|
||||
if (!selectedChannelId && list.length) setSelectedChannelId(list[0].id)
|
||||
} catch {
|
||||
setError('Failed to load channels')
|
||||
setChannels([])
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
if (!session || !selectedGuildId) return
|
||||
setError('')
|
||||
try {
|
||||
const token = await ensureFreshToken()
|
||||
if (!token) return
|
||||
const list = await guildMembersCenter(session.centerApiBase, token, selectedGuildId)
|
||||
setMembers(list)
|
||||
} catch {
|
||||
setError('Failed to load guild members')
|
||||
setMembers([])
|
||||
}
|
||||
}
|
||||
}, [socket, channelId, seqFrom, seqTo, limit, guild, guildToken, session?.user.id])
|
||||
|
||||
async function pullMessages() {
|
||||
if (!channelId || !guild || !guildToken) return
|
||||
if (!selectedChannelId || !guild || !guildToken) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await guildApi().get(`/channels/${channelId}/messages`, {
|
||||
params: {
|
||||
seq_from: Number(seqFrom || '1'),
|
||||
seq_to: Number(seqTo || '999999'),
|
||||
limit: Number(limit || '50'),
|
||||
},
|
||||
const res = await guildApi().get(`/channels/${selectedChannelId}/messages`, {
|
||||
params: { seq_from: 1, seq_to: 999999, limit: 100 },
|
||||
})
|
||||
setMessages(res.data.items ?? [])
|
||||
setPageInfo(res.data.page ?? null)
|
||||
} catch {
|
||||
setError('消息拉取失败')
|
||||
setError('Failed to load messages')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!channelId || !content.trim() || !guild || !guildToken) return
|
||||
if (!selectedChannelId || !content.trim() || !guild || !guildToken) return
|
||||
setError('')
|
||||
try {
|
||||
await guildApi().post(`/channels/${channelId}/messages`, {
|
||||
await guildApi().post(`/channels/${selectedChannelId}/messages`, {
|
||||
content,
|
||||
authorUserId: session?.user.id ?? 'unknown',
|
||||
})
|
||||
socket?.emit('typing.stop', { channelId })
|
||||
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()
|
||||
} catch {
|
||||
setError('编辑失败')
|
||||
setError('Failed to send message')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMessage(messageId: string) {
|
||||
if (!channelId || !messageId || !guild || !guildToken) return
|
||||
async function addGuild() {
|
||||
if (!session || !joinGuildNodeId.trim()) return
|
||||
setError('')
|
||||
try {
|
||||
await guildApi().delete(`/channels/${channelId}/messages/${messageId}`)
|
||||
await pullMessages()
|
||||
const token = await ensureFreshToken()
|
||||
if (!token) return
|
||||
await joinGuildCenter(session.centerApiBase, token, joinGuildNodeId.trim())
|
||||
await refreshGuilds()
|
||||
setJoinGuildNodeId('')
|
||||
} catch {
|
||||
setError('删除失败')
|
||||
setError('Failed to add guild')
|
||||
}
|
||||
}
|
||||
|
||||
function onChangeChannel(value: string) {
|
||||
setChannelId(value)
|
||||
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 toggleMember(userId: string) {
|
||||
setSelectedMemberIds((prev) => (prev.includes(userId) ? prev.filter((x) => x !== userId) : [...prev, userId]))
|
||||
}
|
||||
|
||||
function onTypingChange(value: string) {
|
||||
const wasEmpty = !content.trim()
|
||||
const nowEmpty = !value.trim()
|
||||
setContent(value)
|
||||
if (!channelId || !socket) return
|
||||
if (wasEmpty && !nowEmpty) socket.emit('typing.start', { channelId })
|
||||
if (!wasEmpty && nowEmpty) socket.emit('typing.stop', { channelId })
|
||||
async function createChannel() {
|
||||
if (!guild || !guildToken || !newChannelName.trim()) return
|
||||
if (!guildDbId) {
|
||||
setError('Cannot create channel: guildId is missing')
|
||||
return
|
||||
}
|
||||
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(() => {
|
||||
if (!channelId || !socket || !socket.connected) return
|
||||
socket.emit('join_channel', { channelId })
|
||||
void loadMembers()
|
||||
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 () => {
|
||||
socket.emit('leave_channel', { channelId })
|
||||
socket.emit('leave_channel', { channelId: selectedChannelId })
|
||||
socket.removeAllListeners()
|
||||
socket.disconnect()
|
||||
}
|
||||
}, [channelId, socket, socketState])
|
||||
}, [socket, selectedChannelId])
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>聊天</h2>
|
||||
<p>Guild: {guildId || '-'}</p>
|
||||
<p>Socket: {socketState}</p>
|
||||
<p>在线人数: {onlineCount}</p>
|
||||
{loading ? <p>加载中...</p> : null}
|
||||
{error ? <p style={{ color: 'crimson' }}>{error}</p> : null}
|
||||
{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>
|
||||
<section className="chat-layout">
|
||||
<div className={`chat-top-grid ${!showGuilds ? 'hide-v1' : ''} ${!showChannels ? 'hide-v2' : ''} ${!showMembers ? 'hide-v4' : ''}`}>
|
||||
<div className="panel col-list col-v1">
|
||||
<h3>Guilds</h3>
|
||||
<div className="row-wrap" style={{ marginBottom: 8 }}>
|
||||
<input className="input" value={joinGuildNodeId} onChange={(e) => setJoinGuildNodeId(e.target.value)} placeholder="Guild nodeId" />
|
||||
<button className="btn btn-secondary" onClick={addGuild}>Add guild</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<input value={content} onChange={(e) => onTypingChange(e.target.value)} placeholder="输入消息" />
|
||||
<button onClick={sendMessage}>发送</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<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)}>
|
||||
删除
|
||||
<ul className="list-reset">
|
||||
{guilds.map((g) => (
|
||||
<li key={g.nodeId}>
|
||||
<button className={`list-btn ${selectedGuildId === g.nodeId ? 'active' : ''}`} onClick={() => setSelectedGuildId(g.nodeId)}>
|
||||
{g.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!loading && !messages.length ? <p>暂无消息</p> : null}
|
||||
{pageInfo ? (
|
||||
<p>
|
||||
next_expected_seq: {pageInfo.nextExpectedSeq ?? '-'} | highest_committed_seq: {pageInfo.highestCommittedSeq ?? '-'} |
|
||||
has_more: {String(pageInfo.hasMore ?? false)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="panel col-list col-v2">
|
||||
<h3>Channels</h3>
|
||||
<div className="row-wrap" style={{ marginBottom: 8 }}>
|
||||
<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}
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { login, isAuthed, session } = useAuth()
|
||||
const [centerApiBase, setCenterApiBase] = useState('http://localhost:7001/api')
|
||||
const [centerApiKey, setCenterApiKey] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -16,38 +15,35 @@ export default function LoginPage() {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
try {
|
||||
await login(centerApiBase.trim(), centerApiKey.trim(), email, password)
|
||||
await login(centerApiBase.trim(), email, password)
|
||||
navigate('/workspace')
|
||||
} catch {
|
||||
setError('登录失败,请检查账号密码')
|
||||
setError('Login failed. Please check your email and password.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>登录</h2>
|
||||
{isAuthed ? <p>当前用户:{session?.user.email}</p> : null}
|
||||
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 8, maxWidth: 420 }}>
|
||||
<section className="panel">
|
||||
<h2>Login</h2>
|
||||
{isAuthed ? <p className="muted">Current user: {session?.user.email}</p> : null}
|
||||
<form onSubmit={onSubmit} className="form-grid" style={{ maxWidth: 460 }}>
|
||||
<input
|
||||
className="input"
|
||||
value={centerApiBase}
|
||||
onChange={(e) => setCenterApiBase(e.target.value)}
|
||||
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
|
||||
value={centerApiKey}
|
||||
onChange={(e) => setCenterApiKey(e.target.value)}
|
||||
placeholder="Center API Key"
|
||||
/>
|
||||
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" type="email" />
|
||||
<input
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
<button type="submit">登录</button>
|
||||
<button className="btn" type="submit">Sign in</button>
|
||||
</form>
|
||||
<p>{error}</p>
|
||||
{error ? <p className="error-text">{error}</p> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function WorkspacePage() {
|
||||
const res = await axios.get(`${session.centerApiBase}/healthz`)
|
||||
setHealth(JSON.stringify(res.data))
|
||||
} catch {
|
||||
setHealth('center healthz 访问失败')
|
||||
setHealth('Failed to access center healthz')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,26 +45,29 @@ export default function WorkspacePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>工作台</h2>
|
||||
<p>Center: {session?.centerApiBase || '-'}</p>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<button type="button" onClick={checkCenterHealth}>
|
||||
测试 Center healthz
|
||||
<section className="panel">
|
||||
<h2>Workspace</h2>
|
||||
<p className="muted">Center: {session?.centerApiBase || '-'}</p>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button className="btn" type="button" onClick={checkCenterHealth}>
|
||||
Test Center healthz
|
||||
</button>
|
||||
</div>
|
||||
<p>{health}</p>
|
||||
{health ? <p className="muted">{health}</p> : null}
|
||||
|
||||
<ul>
|
||||
<ul className="list-reset">
|
||||
{(session?.guilds ?? []).map((g) => (
|
||||
<li key={g.nodeId} style={{ marginTop: 8 }}>
|
||||
<strong>{g.name}</strong> ({g.nodeId}) - {g.endpoint}{' '}
|
||||
<button type="button" onClick={() => loadChannels(g.nodeId, g.endpoint)}>
|
||||
加载频道
|
||||
<li key={g.nodeId} className="card" style={{ marginTop: 12 }}>
|
||||
<div>
|
||||
<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>
|
||||
<ul>
|
||||
<ul className="list-reset" style={{ marginTop: 10 }}>
|
||||
{(channelsByGuild[g.nodeId] ?? []).map((c) => (
|
||||
<li key={c.id}>
|
||||
<li key={c.id} style={{ marginTop: 6 }}>
|
||||
<Link
|
||||
to={`/chat?guildId=${encodeURIComponent(g.nodeId)}&channelId=${encodeURIComponent(c.id)}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user