fix(frontend): various UI display and type corrections
- workspaces/[id]: Member type fix - projects/[id]: display adjustments - platform/sessions: session display improvements - env.ts: env type corrections - src/: frontend page and types updates
This commit is contained in:
parent
962bf0312d
commit
a705bdc938
@ -61,6 +61,7 @@ export default function ProjectDetailPage() {
|
||||
// Member management
|
||||
const [showAddMember, setShowAddMember] = useState(false);
|
||||
const [addUserId, setAddUserId] = useState("");
|
||||
const [addUserDisplay, setAddUserDisplay] = useState(""); // shown in the input field
|
||||
const [addScope, setAddScope] = useState("member");
|
||||
const [addMemberLoading, setAddMemberLoading] = useState(false);
|
||||
const [addMemberError, setAddMemberError] = useState("");
|
||||
@ -111,15 +112,24 @@ export default function ProjectDetailPage() {
|
||||
}
|
||||
|
||||
async function handleUserSearch(q: string) {
|
||||
setAddUserId(q);
|
||||
setAddUserId(q); // raw typed text for form submission
|
||||
if (q.length < 2) { setSearchUsers([]); return; }
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/platform/users?search=${encodeURIComponent(q)}&pageSize=10`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setSearchUsers([]);
|
||||
setAddMemberError(data.error || "搜索失败,请重试");
|
||||
return;
|
||||
}
|
||||
const existingIds = new Set(members.map(m => m.uid));
|
||||
setSearchUsers((data.users || []).filter((u: { uid: string }) => !existingIds.has(u.uid)));
|
||||
} catch { setSearchUsers([]); }
|
||||
setAddMemberError("");
|
||||
} catch {
|
||||
setSearchUsers([]);
|
||||
setAddMemberError("搜索失败,请检查网络连接");
|
||||
}
|
||||
finally { setSearchLoading(false); }
|
||||
}
|
||||
|
||||
@ -137,6 +147,7 @@ export default function ProjectDetailPage() {
|
||||
if (!res.ok) { setAddMemberError(data.error || "添加失败"); return; }
|
||||
setShowAddMember(false);
|
||||
setAddUserId("");
|
||||
setAddUserDisplay("");
|
||||
setAddScope("member");
|
||||
setSearchUsers([]);
|
||||
reload();
|
||||
@ -347,8 +358,8 @@ export default function ProjectDetailPage() {
|
||||
<label className="form-label">搜索用户</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={addUserId}
|
||||
onChange={e => handleUserSearch(e.target.value)}
|
||||
value={addUserDisplay || addUserId}
|
||||
onChange={e => { setAddUserDisplay(e.target.value); handleUserSearch(e.target.value); }}
|
||||
placeholder="输入用户名搜索..."
|
||||
autoFocus
|
||||
/>
|
||||
@ -358,7 +369,7 @@ export default function ProjectDetailPage() {
|
||||
<div
|
||||
key={u.uid}
|
||||
style={{ padding: "8px 12px", cursor: "pointer", borderBottom: "1px solid #f0f0f0" }}
|
||||
onClick={() => { setAddUserId(u.uid); setSearchUsers([]); }}
|
||||
onClick={() => { setAddUserId(u.uid); setAddUserDisplay(u.username); setSearchUsers([]); }}
|
||||
>
|
||||
<div style={{ fontWeight: 500, fontSize: "14px" }}>{u.username}</div>
|
||||
<div style={{ fontSize: "12px", color: "#737373" }}>{u.display_name || ""}</div>
|
||||
@ -376,7 +387,7 @@ export default function ProjectDetailPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => { setShowAddMember(false); setSearchUsers([]); }}>取消</button>
|
||||
<button className="btn btn-secondary" onClick={() => { setShowAddMember(false); setSearchUsers([]); setAddUserDisplay(""); }}>取消</button>
|
||||
<button className="btn btn-primary" disabled={addMemberLoading || !addUserId} onClick={handleAddMember}>
|
||||
{addMemberLoading ? "添加中..." : "添加"}
|
||||
</button>
|
||||
|
||||
@ -73,6 +73,7 @@ export default function WorkspaceDetailPage() {
|
||||
// Member management
|
||||
const [showAddMember, setShowAddMember] = useState(false);
|
||||
const [addUserId, setAddUserId] = useState("");
|
||||
const [addUserDisplay, setAddUserDisplay] = useState(""); // shown in the input field
|
||||
const [addRole, setAddRole] = useState("member");
|
||||
const [addMemberLoading, setAddMemberLoading] = useState(false);
|
||||
const [addMemberError, setAddMemberError] = useState("");
|
||||
@ -163,15 +164,25 @@ export default function WorkspaceDetailPage() {
|
||||
|
||||
// Search users for adding member
|
||||
async function handleUserSearch(q: string) {
|
||||
setAddUserId(q);
|
||||
setAddUserId(q); // keep the raw typed text for form submission
|
||||
if (q.length < 2) { setSearchUsers([]); return; }
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/platform/users?search=${encodeURIComponent(q)}&pageSize=10`);
|
||||
const data = await res.json();
|
||||
const existingIds = new Set(members.map(m => m.uid));
|
||||
if (!res.ok) {
|
||||
// Show error from API
|
||||
setSearchUsers([]);
|
||||
setAddMemberError(data.error || "搜索失败,请重试");
|
||||
return;
|
||||
}
|
||||
const existingIds = new Set(members.map(m => m.userId));
|
||||
setSearchUsers((data.users || []).filter((u: { uid: string }) => !existingIds.has(u.uid)));
|
||||
} catch { setSearchUsers([]); }
|
||||
setAddMemberError(""); // clear previous errors
|
||||
} catch {
|
||||
setSearchUsers([]);
|
||||
setAddMemberError("搜索失败,请检查网络连接");
|
||||
}
|
||||
finally { setSearchLoading(false); }
|
||||
}
|
||||
|
||||
@ -189,6 +200,7 @@ export default function WorkspaceDetailPage() {
|
||||
if (!res.ok) { setAddMemberError(data.error || "添加失败"); return; }
|
||||
setShowAddMember(false);
|
||||
setAddUserId("");
|
||||
setAddUserDisplay("");
|
||||
setAddRole("member");
|
||||
setSearchUsers([]);
|
||||
reload();
|
||||
@ -496,8 +508,8 @@ export default function WorkspaceDetailPage() {
|
||||
<label className="form-label">搜索用户</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={addUserId}
|
||||
onChange={(e) => handleUserSearch(e.target.value)}
|
||||
value={addUserDisplay || addUserId}
|
||||
onChange={(e) => { setAddUserDisplay(e.target.value); handleUserSearch(e.target.value); }}
|
||||
placeholder="输入用户名搜索..."
|
||||
autoFocus
|
||||
/>
|
||||
@ -509,6 +521,7 @@ export default function WorkspaceDetailPage() {
|
||||
style={{ padding: "8px 12px", cursor: "pointer", borderBottom: "1px solid #f0f0f0" }}
|
||||
onClick={() => {
|
||||
setAddUserId(u.uid);
|
||||
setAddUserDisplay(u.username);
|
||||
setSearchUsers([]);
|
||||
}}
|
||||
>
|
||||
@ -528,7 +541,7 @@ export default function WorkspaceDetailPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => { setShowAddMember(false); setSearchUsers([]); }}>取消</button>
|
||||
<button className="btn btn-secondary" onClick={() => { setShowAddMember(false); setSearchUsers([]); setAddUserDisplay(""); }}>取消</button>
|
||||
<button className="btn btn-primary" disabled={addMemberLoading || !addUserId} onClick={handleAddMember}>
|
||||
{addMemberLoading ? "添加中..." : "添加"}
|
||||
</button>
|
||||
|
||||
@ -2,8 +2,14 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
listUserSessions,
|
||||
kickUser,
|
||||
getUserStatus,
|
||||
type UserSession,
|
||||
} from "@/lib/admin-rpc";
|
||||
|
||||
interface SessionInfo {
|
||||
interface PlatformSessionInfo {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
username: string | null;
|
||||
@ -14,14 +20,17 @@ interface SessionInfo {
|
||||
}
|
||||
|
||||
export default function PlatformSessionsPage() {
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [sessions, setSessions] = useState<PlatformSessionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [kicking, setKicking] = useState<string | null>(null);
|
||||
const [adminRpcAvailable, setAdminRpcAvailable] = useState(false);
|
||||
|
||||
useEffect(() => { loadSessions(); }, []);
|
||||
|
||||
async function loadSessions() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Load platform sessions from the app's REST API (Redis-based)
|
||||
const res = await fetch("/api/platform/sessions");
|
||||
if (!res.ok) {
|
||||
setSessions([]);
|
||||
@ -37,6 +46,31 @@ export default function PlatformSessionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKickAllSessions(userId: string) {
|
||||
if (!confirm(`强制下线用户 ${userId} 的所有会话?`)) return;
|
||||
setKicking(userId);
|
||||
try {
|
||||
if (adminRpcAvailable) {
|
||||
await kickUser(userId);
|
||||
} else {
|
||||
// Fallback: kick sessions via Redis (app REST API)
|
||||
const userSessions = sessions.filter((s) => s.userId === userId);
|
||||
await Promise.all(
|
||||
userSessions.map((s) =>
|
||||
fetch("/api/platform/sessions", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId: s.sessionId }),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
loadSessions();
|
||||
} finally {
|
||||
setKicking(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKickSession(sessionId: string) {
|
||||
if (!confirm("确定强制下线该会话吗?")) return;
|
||||
setKicking(sessionId);
|
||||
@ -47,11 +81,13 @@ export default function PlatformSessionsPage() {
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
loadSessions();
|
||||
} finally { setKicking(null); }
|
||||
} finally {
|
||||
setKicking(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Group by user
|
||||
const byUser = sessions.reduce<Record<string, SessionInfo[]>>((acc, s) => {
|
||||
const byUser = sessions.reduce<Record<string, PlatformSessionInfo[]>>((acc, s) => {
|
||||
const key = s.userId || "unknown";
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(s);
|
||||
@ -65,9 +101,30 @@ export default function PlatformSessionsPage() {
|
||||
<h1 className="page-title">平台用户在线会话</h1>
|
||||
<p className="page-subtitle">
|
||||
共 {sessions.length} 个活跃会话,涉及 {Object.keys(byUser).length} 个用户
|
||||
{adminRpcAvailable && (
|
||||
<span style={{ marginLeft: "8px", color: "#22c55e", fontSize: "12px" }}>
|
||||
● adminrpc 已连接
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={loadSessions}>刷新</button>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const { adminRpcHealth } = await import("@/lib/admin-rpc");
|
||||
await adminRpcHealth();
|
||||
setAdminRpcAvailable(true);
|
||||
} catch {
|
||||
setAdminRpcAvailable(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
检测 adminrpc
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={loadSessions}>刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@ -91,23 +148,19 @@ export default function PlatformSessionsPage() {
|
||||
<span className="badge badge-neutral" style={{ marginLeft: "8px" }}>
|
||||
{userSessions.length} 个会话
|
||||
</span>
|
||||
{adminRpcAvailable && (
|
||||
<AdminRpcUserStatus userId={userId} />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={async () => {
|
||||
if (!confirm(`强制下线用户 ${userId} 的所有会话?`)) return;
|
||||
for (const s of userSessions) {
|
||||
await fetch("/api/platform/sessions", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId: s.sessionId }),
|
||||
});
|
||||
}
|
||||
loadSessions();
|
||||
}}
|
||||
>
|
||||
全部下线
|
||||
</button>
|
||||
{adminRpcAvailable && (
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
disabled={kicking === userId}
|
||||
onClick={() => handleKickAllSessions(userId)}
|
||||
>
|
||||
{kicking === userId ? "处理中..." : "全部下线 (adminrpc)"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
@ -115,6 +168,7 @@ export default function PlatformSessionsPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Session ID</th>
|
||||
<th>工作空间</th>
|
||||
<th>登录时间</th>
|
||||
<th>IP</th>
|
||||
<th>User Agent</th>
|
||||
@ -129,10 +183,15 @@ export default function PlatformSessionsPage() {
|
||||
{s.sessionId.slice(0, 16)}...
|
||||
</code>
|
||||
</td>
|
||||
<td style={{ fontSize: "12px" }}>
|
||||
{s.workspaceId ? (
|
||||
<code>{s.workspaceId.slice(0, 8)}...</code>
|
||||
) : "—"}
|
||||
</td>
|
||||
<td>{s.createdAt ? format(new Date(s.createdAt), "yyyy-MM-dd HH:mm") : "—"}</td>
|
||||
<td style={{ fontSize: "13px" }}>{s.ipAddress || "—"}</td>
|
||||
<td
|
||||
style={{ fontSize: "12px", maxWidth: "280px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
style={{ fontSize: "12px", maxWidth: "240px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
title={s.userAgent || ""}
|
||||
>
|
||||
{s.userAgent || "—"}
|
||||
@ -143,7 +202,7 @@ export default function PlatformSessionsPage() {
|
||||
disabled={kicking === s.sessionId}
|
||||
onClick={() => handleKickSession(s.sessionId)}
|
||||
>
|
||||
{kicking === s.sessionId ? "处理中..." : "下线"}
|
||||
{kicking === s.sessionId ? "..." : "下线"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@ -157,3 +216,23 @@ export default function PlatformSessionsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── AdminRpc Status Badge ────────────────────────────────────────────────────
|
||||
|
||||
function AdminRpcUserStatus({ userId }: { userId: string }) {
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getUserStatus(userId)
|
||||
.then((r) => setStatus(r.status))
|
||||
.catch(() => setStatus(null));
|
||||
}, [userId]);
|
||||
|
||||
if (!status) return null;
|
||||
const color = status === "Online" ? "#22c55e" : "#f59e0b";
|
||||
return (
|
||||
<span style={{ marginLeft: "8px", color, fontSize: "12px", fontWeight: 500 }}>
|
||||
[{status}]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -48,3 +48,8 @@ export const RUST_BACKEND_URL =
|
||||
process.env.RUST_BACKEND_URL || "http://localhost:3000";
|
||||
export const ADMIN_API_SHARED_KEY =
|
||||
process.env.ADMIN_API_SHARED_KEY || "";
|
||||
|
||||
// adminrpc HTTP 服务地址(k8s 内部默认地址)
|
||||
// 在 Kubernetes 环境中默认使用 Service DNS,在本地开发时覆盖为 localhost:9091
|
||||
export const ADMIN_RPC_URL =
|
||||
process.env.ADMIN_RPC_URL || "http://adminrpc.admin.svc.cluster.local:9091";
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {useEffect} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {LandingNav} from '@/components/landing/landing-nav';
|
||||
import {LandingHero, LandingFeatures, LandingHighlight} from '@/components/landing/landing-sections';
|
||||
import {LandingFeatures, LandingHero, LandingHighlight} from '@/components/landing/landing-sections';
|
||||
import {LandingFooter} from '@/components/landing/landing-footer';
|
||||
import {useUser} from '@/contexts/user-context';
|
||||
|
||||
@ -9,25 +9,19 @@ export default function GitDataLandingPage() {
|
||||
const navigate = useNavigate();
|
||||
const {isAuthenticated} = useUser();
|
||||
const handleRegister = () => navigate('/auth/register');
|
||||
|
||||
// Redirect authenticated users to workspace
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/w/me', {replace: true});
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
// Don't render landing page for authenticated users
|
||||
if (isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 font-sans antialiased">
|
||||
{/* Subtle background grid */}
|
||||
<div
|
||||
className="fixed inset-0 bg-[linear-gradient(to_right,#09090b08_1px,transparent_1px),linear-gradient(to_bottom,#09090b08_1px,transparent_1px)] bg-[size:24px_24px] pointer-events-none"/>
|
||||
|
||||
<LandingNav/>
|
||||
<main>
|
||||
<LandingHero onRegister={handleRegister}/>
|
||||
|
||||
@ -4360,6 +4360,7 @@ export type RoomAiResponse = {
|
||||
think: boolean;
|
||||
stream: boolean;
|
||||
min_score?: number | null;
|
||||
agent_type?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@ -4375,6 +4376,7 @@ export type RoomAiUpsertRequest = {
|
||||
think?: boolean | null;
|
||||
stream?: boolean | null;
|
||||
min_score?: number | null;
|
||||
agent_type?: string | null;
|
||||
};
|
||||
|
||||
export type RoomCategoryCreateRequest = {
|
||||
|
||||
@ -55,6 +55,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
const [useExact, setUseExact] = useState(false);
|
||||
const [think, setThink] = useState(false);
|
||||
const [stream, setStream] = useState(true);
|
||||
const [agentType, setAgentType] = useState('chat');
|
||||
const [isAddingAi, setIsAddingAi] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
@ -110,6 +111,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
setUseExact(false);
|
||||
setThink(false);
|
||||
setStream(true);
|
||||
setAgentType('chat');
|
||||
setShowAdvanced(false);
|
||||
setShowAiAddDialog(true);
|
||||
loadModels();
|
||||
@ -131,6 +133,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
use_exact: useExact,
|
||||
think,
|
||||
stream,
|
||||
agent_type: agentType || undefined,
|
||||
};
|
||||
await aiUpsert({
|
||||
path: { room_id: room.id },
|
||||
@ -275,6 +278,14 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
think
|
||||
</span>
|
||||
)}
|
||||
{config.agent_type === 'react' && (
|
||||
<span
|
||||
className="rounded px-1 py-0.5 text-[10px] shrink-0"
|
||||
style={{ background: 'rgba(168,85,247,0.15)', color: '#c084fc' }}
|
||||
>
|
||||
react
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -363,6 +374,32 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent type */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm" style={{ color: 'var(--room-text)' }}>Agent Type</Label>
|
||||
<Select value={agentType} onValueChange={(v) => { if (v !== null) setAgentType(v); }}>
|
||||
<SelectTrigger className="w-full" style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)', color: 'var(--room-text)' }}>
|
||||
<SelectValue>
|
||||
{agentType === 'react' ? 'ReAct (multi-step reasoning)' : 'Chat (simple)'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent style={{ background: 'var(--room-bg)', border: '1px solid var(--room-border)' }}>
|
||||
<SelectItem value="chat">
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<span className="font-medium">Chat</span>
|
||||
<span className="text-xs" style={{ color: 'var(--room-text-muted)' }}>Simple single-turn response</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="react">
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<span className="font-medium">ReAct</span>
|
||||
<span className="text-xs" style={{ color: 'var(--room-text-muted)' }}>Multi-step reasoning with tool use</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings toggle */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user