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
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>

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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";

View File

@ -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}/>

View File

@ -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 = {

View File

@ -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"