fix(frontend): various UI display and type corrections
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions

- 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:
ZhenYi 2026-04-22 10:31:33 +08:00
parent 962bf0312d
commit a705bdc938
7 changed files with 183 additions and 42 deletions

View File

@ -61,6 +61,7 @@ export default function ProjectDetailPage() {
// Member management // Member management
const [showAddMember, setShowAddMember] = useState(false); const [showAddMember, setShowAddMember] = useState(false);
const [addUserId, setAddUserId] = useState(""); const [addUserId, setAddUserId] = useState("");
const [addUserDisplay, setAddUserDisplay] = useState(""); // shown in the input field
const [addScope, setAddScope] = useState("member"); const [addScope, setAddScope] = useState("member");
const [addMemberLoading, setAddMemberLoading] = useState(false); const [addMemberLoading, setAddMemberLoading] = useState(false);
const [addMemberError, setAddMemberError] = useState(""); const [addMemberError, setAddMemberError] = useState("");
@ -111,15 +112,24 @@ export default function ProjectDetailPage() {
} }
async function handleUserSearch(q: string) { async function handleUserSearch(q: string) {
setAddUserId(q); setAddUserId(q); // raw typed text for form submission
if (q.length < 2) { setSearchUsers([]); return; } if (q.length < 2) { setSearchUsers([]); return; }
setSearchLoading(true); setSearchLoading(true);
try { try {
const res = await fetch(`/api/platform/users?search=${encodeURIComponent(q)}&pageSize=10`); const res = await fetch(`/api/platform/users?search=${encodeURIComponent(q)}&pageSize=10`);
const data = await res.json(); const data = await res.json();
if (!res.ok) {
setSearchUsers([]);
setAddMemberError(data.error || "搜索失败,请重试");
return;
}
const existingIds = new Set(members.map(m => m.uid)); const existingIds = new Set(members.map(m => m.uid));
setSearchUsers((data.users || []).filter((u: { uid: string }) => !existingIds.has(u.uid))); setSearchUsers((data.users || []).filter((u: { uid: string }) => !existingIds.has(u.uid)));
} catch { setSearchUsers([]); } setAddMemberError("");
} catch {
setSearchUsers([]);
setAddMemberError("搜索失败,请检查网络连接");
}
finally { setSearchLoading(false); } finally { setSearchLoading(false); }
} }
@ -137,6 +147,7 @@ export default function ProjectDetailPage() {
if (!res.ok) { setAddMemberError(data.error || "添加失败"); return; } if (!res.ok) { setAddMemberError(data.error || "添加失败"); return; }
setShowAddMember(false); setShowAddMember(false);
setAddUserId(""); setAddUserId("");
setAddUserDisplay("");
setAddScope("member"); setAddScope("member");
setSearchUsers([]); setSearchUsers([]);
reload(); reload();
@ -347,8 +358,8 @@ export default function ProjectDetailPage() {
<label className="form-label"></label> <label className="form-label"></label>
<input <input
className="form-input" className="form-input"
value={addUserId} value={addUserDisplay || addUserId}
onChange={e => handleUserSearch(e.target.value)} onChange={e => { setAddUserDisplay(e.target.value); handleUserSearch(e.target.value); }}
placeholder="输入用户名搜索..." placeholder="输入用户名搜索..."
autoFocus autoFocus
/> />
@ -358,7 +369,7 @@ export default function ProjectDetailPage() {
<div <div
key={u.uid} key={u.uid}
style={{ padding: "8px 12px", cursor: "pointer", borderBottom: "1px solid #f0f0f0" }} 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={{ fontWeight: 500, fontSize: "14px" }}>{u.username}</div>
<div style={{ fontSize: "12px", color: "#737373" }}>{u.display_name || ""}</div> <div style={{ fontSize: "12px", color: "#737373" }}>{u.display_name || ""}</div>
@ -376,7 +387,7 @@ export default function ProjectDetailPage() {
</select> </select>
</div> </div>
<div className="modal-footer"> <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}> <button className="btn btn-primary" disabled={addMemberLoading || !addUserId} onClick={handleAddMember}>
{addMemberLoading ? "添加中..." : "添加"} {addMemberLoading ? "添加中..." : "添加"}
</button> </button>

View File

@ -73,6 +73,7 @@ export default function WorkspaceDetailPage() {
// Member management // Member management
const [showAddMember, setShowAddMember] = useState(false); const [showAddMember, setShowAddMember] = useState(false);
const [addUserId, setAddUserId] = useState(""); const [addUserId, setAddUserId] = useState("");
const [addUserDisplay, setAddUserDisplay] = useState(""); // shown in the input field
const [addRole, setAddRole] = useState("member"); const [addRole, setAddRole] = useState("member");
const [addMemberLoading, setAddMemberLoading] = useState(false); const [addMemberLoading, setAddMemberLoading] = useState(false);
const [addMemberError, setAddMemberError] = useState(""); const [addMemberError, setAddMemberError] = useState("");
@ -163,15 +164,25 @@ export default function WorkspaceDetailPage() {
// Search users for adding member // Search users for adding member
async function handleUserSearch(q: string) { async function handleUserSearch(q: string) {
setAddUserId(q); setAddUserId(q); // keep the raw typed text for form submission
if (q.length < 2) { setSearchUsers([]); return; } if (q.length < 2) { setSearchUsers([]); return; }
setSearchLoading(true); setSearchLoading(true);
try { try {
const res = await fetch(`/api/platform/users?search=${encodeURIComponent(q)}&pageSize=10`); const res = await fetch(`/api/platform/users?search=${encodeURIComponent(q)}&pageSize=10`);
const data = await res.json(); 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))); setSearchUsers((data.users || []).filter((u: { uid: string }) => !existingIds.has(u.uid)));
} catch { setSearchUsers([]); } setAddMemberError(""); // clear previous errors
} catch {
setSearchUsers([]);
setAddMemberError("搜索失败,请检查网络连接");
}
finally { setSearchLoading(false); } finally { setSearchLoading(false); }
} }
@ -189,6 +200,7 @@ export default function WorkspaceDetailPage() {
if (!res.ok) { setAddMemberError(data.error || "添加失败"); return; } if (!res.ok) { setAddMemberError(data.error || "添加失败"); return; }
setShowAddMember(false); setShowAddMember(false);
setAddUserId(""); setAddUserId("");
setAddUserDisplay("");
setAddRole("member"); setAddRole("member");
setSearchUsers([]); setSearchUsers([]);
reload(); reload();
@ -496,8 +508,8 @@ export default function WorkspaceDetailPage() {
<label className="form-label"></label> <label className="form-label"></label>
<input <input
className="form-input" className="form-input"
value={addUserId} value={addUserDisplay || addUserId}
onChange={(e) => handleUserSearch(e.target.value)} onChange={(e) => { setAddUserDisplay(e.target.value); handleUserSearch(e.target.value); }}
placeholder="输入用户名搜索..." placeholder="输入用户名搜索..."
autoFocus autoFocus
/> />
@ -509,6 +521,7 @@ export default function WorkspaceDetailPage() {
style={{ padding: "8px 12px", cursor: "pointer", borderBottom: "1px solid #f0f0f0" }} style={{ padding: "8px 12px", cursor: "pointer", borderBottom: "1px solid #f0f0f0" }}
onClick={() => { onClick={() => {
setAddUserId(u.uid); setAddUserId(u.uid);
setAddUserDisplay(u.username);
setSearchUsers([]); setSearchUsers([]);
}} }}
> >
@ -528,7 +541,7 @@ export default function WorkspaceDetailPage() {
</select> </select>
</div> </div>
<div className="modal-footer"> <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}> <button className="btn btn-primary" disabled={addMemberLoading || !addUserId} onClick={handleAddMember}>
{addMemberLoading ? "添加中..." : "添加"} {addMemberLoading ? "添加中..." : "添加"}
</button> </button>

View File

@ -2,8 +2,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import {
listUserSessions,
kickUser,
getUserStatus,
type UserSession,
} from "@/lib/admin-rpc";
interface SessionInfo { interface PlatformSessionInfo {
sessionId: string; sessionId: string;
userId: string; userId: string;
username: string | null; username: string | null;
@ -14,14 +20,17 @@ interface SessionInfo {
} }
export default function PlatformSessionsPage() { export default function PlatformSessionsPage() {
const [sessions, setSessions] = useState<SessionInfo[]>([]); const [sessions, setSessions] = useState<PlatformSessionInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [kicking, setKicking] = useState<string | null>(null); const [kicking, setKicking] = useState<string | null>(null);
const [adminRpcAvailable, setAdminRpcAvailable] = useState(false);
useEffect(() => { loadSessions(); }, []); useEffect(() => { loadSessions(); }, []);
async function loadSessions() { async function loadSessions() {
setLoading(true); setLoading(true);
try { try {
// Load platform sessions from the app's REST API (Redis-based)
const res = await fetch("/api/platform/sessions"); const res = await fetch("/api/platform/sessions");
if (!res.ok) { if (!res.ok) {
setSessions([]); 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) { async function handleKickSession(sessionId: string) {
if (!confirm("确定强制下线该会话吗?")) return; if (!confirm("确定强制下线该会话吗?")) return;
setKicking(sessionId); setKicking(sessionId);
@ -47,11 +81,13 @@ export default function PlatformSessionsPage() {
body: JSON.stringify({ sessionId }), body: JSON.stringify({ sessionId }),
}); });
loadSessions(); loadSessions();
} finally { setKicking(null); } } finally {
setKicking(null);
}
} }
// Group by user // 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"; const key = s.userId || "unknown";
if (!acc[key]) acc[key] = []; if (!acc[key]) acc[key] = [];
acc[key].push(s); acc[key].push(s);
@ -65,10 +101,31 @@ export default function PlatformSessionsPage() {
<h1 className="page-title">线</h1> <h1 className="page-title">线</h1>
<p className="page-subtitle"> <p className="page-subtitle">
{sessions.length} {Object.keys(byUser).length} {sessions.length} {Object.keys(byUser).length}
{adminRpcAvailable && (
<span style={{ marginLeft: "8px", color: "#22c55e", fontSize: "12px" }}>
adminrpc
</span>
)}
</p> </p>
</div> </div>
<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> <button className="btn btn-secondary" onClick={loadSessions}></button>
</div> </div>
</div>
{loading ? ( {loading ? (
<div className="loading">...</div> <div className="loading">...</div>
@ -91,23 +148,19 @@ export default function PlatformSessionsPage() {
<span className="badge badge-neutral" style={{ marginLeft: "8px" }}> <span className="badge badge-neutral" style={{ marginLeft: "8px" }}>
{userSessions.length} {userSessions.length}
</span> </span>
{adminRpcAvailable && (
<AdminRpcUserStatus userId={userId} />
)}
</div> </div>
{adminRpcAvailable && (
<button <button
className="btn btn-danger btn-sm" className="btn btn-danger btn-sm"
onClick={async () => { disabled={kicking === userId}
if (!confirm(`强制下线用户 ${userId} 的所有会话?`)) return; onClick={() => handleKickAllSessions(userId)}
for (const s of userSessions) {
await fetch("/api/platform/sessions", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: s.sessionId }),
});
}
loadSessions();
}}
> >
线 {kicking === userId ? "处理中..." : "全部下线 (adminrpc)"}
</button> </button>
)}
</div> </div>
<div className="table-container"> <div className="table-container">
@ -115,6 +168,7 @@ export default function PlatformSessionsPage() {
<thead> <thead>
<tr> <tr>
<th>Session ID</th> <th>Session ID</th>
<th></th>
<th></th> <th></th>
<th>IP</th> <th>IP</th>
<th>User Agent</th> <th>User Agent</th>
@ -129,10 +183,15 @@ export default function PlatformSessionsPage() {
{s.sessionId.slice(0, 16)}... {s.sessionId.slice(0, 16)}...
</code> </code>
</td> </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>{s.createdAt ? format(new Date(s.createdAt), "yyyy-MM-dd HH:mm") : "—"}</td>
<td style={{ fontSize: "13px" }}>{s.ipAddress || "—"}</td> <td style={{ fontSize: "13px" }}>{s.ipAddress || "—"}</td>
<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 || ""} title={s.userAgent || ""}
> >
{s.userAgent || "—"} {s.userAgent || "—"}
@ -143,7 +202,7 @@ export default function PlatformSessionsPage() {
disabled={kicking === s.sessionId} disabled={kicking === s.sessionId}
onClick={() => handleKickSession(s.sessionId)} onClick={() => handleKickSession(s.sessionId)}
> >
{kicking === s.sessionId ? "处理中..." : "下线"} {kicking === s.sessionId ? "..." : "下线"}
</button> </button>
</td> </td>
</tr> </tr>
@ -157,3 +216,23 @@ export default function PlatformSessionsPage() {
</div> </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>
);
}

View File

@ -48,3 +48,8 @@ export const RUST_BACKEND_URL =
process.env.RUST_BACKEND_URL || "http://localhost:3000"; process.env.RUST_BACKEND_URL || "http://localhost:3000";
export const ADMIN_API_SHARED_KEY = export const ADMIN_API_SHARED_KEY =
process.env.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";

View File

@ -1,7 +1,7 @@
import {useEffect} from 'react'; import {useEffect} from 'react';
import {useNavigate} from 'react-router-dom'; import {useNavigate} from 'react-router-dom';
import {LandingNav} from '@/components/landing/landing-nav'; 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 {LandingFooter} from '@/components/landing/landing-footer';
import {useUser} from '@/contexts/user-context'; import {useUser} from '@/contexts/user-context';
@ -9,25 +9,19 @@ export default function GitDataLandingPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const {isAuthenticated} = useUser(); const {isAuthenticated} = useUser();
const handleRegister = () => navigate('/auth/register'); const handleRegister = () => navigate('/auth/register');
// Redirect authenticated users to workspace
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
navigate('/w/me', {replace: true}); navigate('/w/me', {replace: true});
} }
}, [isAuthenticated, navigate]); }, [isAuthenticated, navigate]);
// Don't render landing page for authenticated users
if (isAuthenticated) { if (isAuthenticated) {
return null; return null;
} }
return ( return (
<div className="min-h-screen bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 font-sans antialiased"> <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 <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"/> 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/> <LandingNav/>
<main> <main>
<LandingHero onRegister={handleRegister}/> <LandingHero onRegister={handleRegister}/>

View File

@ -4360,6 +4360,7 @@ export type RoomAiResponse = {
think: boolean; think: boolean;
stream: boolean; stream: boolean;
min_score?: number | null; min_score?: number | null;
agent_type?: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@ -4375,6 +4376,7 @@ export type RoomAiUpsertRequest = {
think?: boolean | null; think?: boolean | null;
stream?: boolean | null; stream?: boolean | null;
min_score?: number | null; min_score?: number | null;
agent_type?: string | null;
}; };
export type RoomCategoryCreateRequest = { export type RoomCategoryCreateRequest = {

View File

@ -55,6 +55,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
const [useExact, setUseExact] = useState(false); const [useExact, setUseExact] = useState(false);
const [think, setThink] = useState(false); const [think, setThink] = useState(false);
const [stream, setStream] = useState(true); const [stream, setStream] = useState(true);
const [agentType, setAgentType] = useState('chat');
const [isAddingAi, setIsAddingAi] = useState(false); const [isAddingAi, setIsAddingAi] = useState(false);
const handleSave = async () => { const handleSave = async () => {
@ -110,6 +111,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
setUseExact(false); setUseExact(false);
setThink(false); setThink(false);
setStream(true); setStream(true);
setAgentType('chat');
setShowAdvanced(false); setShowAdvanced(false);
setShowAiAddDialog(true); setShowAiAddDialog(true);
loadModels(); loadModels();
@ -131,6 +133,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
use_exact: useExact, use_exact: useExact,
think, think,
stream, stream,
agent_type: agentType || undefined,
}; };
await aiUpsert({ await aiUpsert({
path: { room_id: room.id }, path: { room_id: room.id },
@ -275,6 +278,14 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
think think
</span> </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> </div>
<Button <Button
variant="ghost" variant="ghost"
@ -363,6 +374,32 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
/> />
</div> </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 */} {/* Advanced settings toggle */}
<button <button
type="button" type="button"