fix(data): 清理旧数据并保护服务端写入
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, X, ChevronRight, FolderOpen, Settings } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, X, ChevronRight, FolderOpen, Settings, Search } from 'lucide-react';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { maskPhone, generatePassword } from '@/lib/members';
|
||||
import { generatePassword, generateUsernameFromName } from '@/lib/members';
|
||||
import type { Member, Department, PasswordRule } from '@/lib/members';
|
||||
import { isSystemAdminMember } from '@/lib/member-system';
|
||||
import { filterMembersForList, getDisplayedAccountPassword, resolveLatestEditingMember } from '@/lib/member-list';
|
||||
import { RouteGuard } from '@/components/auth/Guard';
|
||||
|
||||
export default function MembersPage() {
|
||||
@@ -22,6 +23,7 @@ function MembersPageContent() {
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const refreshUser = useAuthStore((s) => s.refreshUser);
|
||||
const [activeDeptId, setActiveDeptId] = useState<string | null>(null);
|
||||
const [nameKeyword, setNameKeyword] = useState('');
|
||||
const [showMemberModal, setShowMemberModal] = useState(false);
|
||||
const [editingMember, setEditingMember] = useState<Member | null>(null);
|
||||
const [showDeptModal, setShowDeptModal] = useState(false);
|
||||
@@ -35,10 +37,12 @@ function MembersPageContent() {
|
||||
const childDepts = (parentId: string) => departments.filter((d) => d.parentId === parentId).sort((a, b) => a.order - b.order);
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
if (!activeDeptId) return members;
|
||||
const deptIds = [activeDeptId, ...departments.filter((d) => d.parentId === activeDeptId).map((d) => d.id)];
|
||||
return members.filter((m) => deptIds.includes(m.departmentId));
|
||||
}, [members, activeDeptId, departments]);
|
||||
return filterMembersForList({ members, departments, activeDeptId, nameKeyword });
|
||||
}, [members, departments, activeDeptId, nameKeyword]);
|
||||
const latestEditingMember = useMemo(
|
||||
() => resolveLatestEditingMember(editingMember, members),
|
||||
[editingMember, members],
|
||||
);
|
||||
|
||||
const activeDeptName = activeDeptId ? departments.find((d) => d.id === activeDeptId)?.name ?? '全部' : '全部成员';
|
||||
const roleName = (roleId: string) => roles.find((r) => r.id === roleId)?.name ?? '-';
|
||||
@@ -131,9 +135,18 @@ function MembersPageContent() {
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{filteredMembers.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" />
|
||||
<input
|
||||
value={nameKeyword}
|
||||
onChange={(e) => setNameKeyword(e.target.value)}
|
||||
placeholder="搜索姓名"
|
||||
className="h-8 w-44 rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => setShowPwdRuleModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
|
||||
<Settings className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
初始密码设置
|
||||
账号密码设置
|
||||
</button>
|
||||
<button onClick={() => { setEditingMember(null); setShowMemberModal(true); }} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
@@ -153,9 +166,9 @@ function MembersPageContent() {
|
||||
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
|
||||
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">姓名</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">用户名</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">角色</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">部门</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">手机号</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">企业邮箱</th>
|
||||
<th className="px-4 py-2.5 text-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">操作</th>
|
||||
</tr>
|
||||
@@ -164,11 +177,11 @@ function MembersPageContent() {
|
||||
{filteredMembers.map((m) => (
|
||||
<tr key={m.id} className="border-b border-[var(--line-soft)] last:border-0 hover:bg-[var(--bg-subtle)] transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-[var(--ink)]">{m.name}</td>
|
||||
<td className="px-4 py-3 font-mono text-[var(--ink-soft)]">{m.username ?? '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center rounded-md bg-[var(--bg-subtle)] px-2 py-0.5 text-[11px] font-medium text-[var(--ink-soft)]">{roleName(m.roleId)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--ink-soft)]">{departments.find((d) => d.id === m.departmentId)?.name ?? '-'}</td>
|
||||
<td className="px-4 py-3 tabular-nums text-[var(--ink-soft)]">{maskPhone(m.phone)}</td>
|
||||
<td className="px-4 py-3 text-[var(--ink-soft)]">{m.email}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
@@ -193,9 +206,10 @@ function MembersPageContent() {
|
||||
{/* Member Modal */}
|
||||
{showMemberModal && (
|
||||
<MemberModal
|
||||
initial={editingMember}
|
||||
initial={latestEditingMember}
|
||||
departments={departments}
|
||||
roles={roles}
|
||||
existingUsernames={members.map((m) => m.username ?? '')}
|
||||
defaultDeptId={activeDeptId}
|
||||
passwordRule={passwordRule}
|
||||
onClose={() => setShowMemberModal(false)}
|
||||
@@ -237,10 +251,11 @@ function MembersPageContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function MemberModal({ initial, departments, roles, defaultDeptId, passwordRule, onClose, onSubmit }: {
|
||||
function MemberModal({ initial, departments, roles, existingUsernames, defaultDeptId, passwordRule, onClose, onSubmit }: {
|
||||
initial: Member | null;
|
||||
departments: Department[];
|
||||
roles: { id: string; name: string }[];
|
||||
existingUsernames: string[];
|
||||
defaultDeptId: string | null;
|
||||
passwordRule: PasswordRule;
|
||||
onClose: () => void;
|
||||
@@ -249,15 +264,23 @@ function MemberModal({ initial, departments, roles, defaultDeptId, passwordRule,
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [departmentId, setDepartmentId] = useState(initial?.departmentId ?? defaultDeptId ?? '');
|
||||
const [roleId, setRoleId] = useState(initial?.roleId ?? '');
|
||||
const [phone, setPhone] = useState(initial?.phone ?? '');
|
||||
const [email, setEmail] = useState(initial?.email ?? '');
|
||||
const [password] = useState(() => initial?.password ?? generatePassword(passwordRule));
|
||||
const [generatedPassword] = useState(() => generatePassword(passwordRule));
|
||||
const password = getDisplayedAccountPassword(initial, generatedPassword);
|
||||
const isSystemMember = initial ? isSystemAdminMember(initial) : false;
|
||||
const usernamePreview = initial
|
||||
? (initial.username || generateUsernameFromName(initial.name, existingUsernames))
|
||||
: (name.trim() ? generateUsernameFromName(name, existingUsernames) : '');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !departmentId || !roleId) return;
|
||||
onSubmit({ name: name.trim(), departmentId, roleId, phone: phone.trim(), email: email.trim(), password });
|
||||
const data: Partial<Member> = { name: name.trim(), departmentId, roleId, email: email.trim(), password };
|
||||
if (!initial) {
|
||||
data.username = usernamePreview;
|
||||
data.phone = '';
|
||||
}
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -277,6 +300,15 @@ function MemberModal({ initial, departments, roles, defaultDeptId, passwordRule,
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">姓名 *</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} required disabled={isSystemMember} placeholder="请输入姓名" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none disabled:bg-zinc-50 disabled:text-[var(--ink-soft)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">用户名</label>
|
||||
<div className="flex h-9 w-full items-center rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3">
|
||||
<span className="flex-1 font-mono text-[13px] text-[var(--ink)]">{usernamePreview || '输入姓名后自动生成'}</span>
|
||||
{!initial && (
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">自动生成</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">部门 *</label>
|
||||
@@ -293,16 +325,12 @@ function MemberModal({ initial, departments, roles, defaultDeptId, passwordRule,
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">手机号</label>
|
||||
<input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="11位手机号" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">企业邮箱</label>
|
||||
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@company.com" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">初始密码</label>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">账号密码</label>
|
||||
<div className="flex items-center h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3">
|
||||
<span className="flex-1 text-[13px] font-mono text-[var(--ink)]">{password}</span>
|
||||
{!initial && (
|
||||
@@ -385,7 +413,7 @@ function PasswordRuleModal({ rule, onClose, onSave }: {
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="w-full max-w-sm rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">初始密码规则设置</h3>
|
||||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">账号密码规则设置</h3>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { Eye, EyeOff, LayoutGrid, Lock, Phone } from 'lucide-react';
|
||||
import { Eye, EyeOff, LayoutGrid, Lock, User } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuthStore();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [remember, setRemember] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -19,18 +19,18 @@ export default function LoginPage() {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!phone.trim() || !password.trim()) {
|
||||
setError('请输入手机号和密码');
|
||||
if (!username.trim() || !password.trim()) {
|
||||
setError('请输入用户名和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const success = await login(phone.trim(), password, remember);
|
||||
const success = await login(username.trim(), password, remember);
|
||||
if (success) {
|
||||
router.push('/products');
|
||||
} else {
|
||||
setError('手机号或密码错误');
|
||||
setError('用户名或密码错误');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -51,15 +51,13 @@ export default function LoginPage() {
|
||||
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)]">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">手机号</label>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">用户名</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)]" />
|
||||
<User className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)]" />
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="请输入手机号"
|
||||
maxLength={11}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-3 text-[14px] text-[var(--ink)] transition-all placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||
/>
|
||||
</div>
|
||||
@@ -113,7 +111,7 @@ export default function LoginPage() {
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-[11px] text-[var(--ink-muted)]">
|
||||
默认账号:13200132008 / Ftb@2024(超级管理员)
|
||||
默认账号:admin / Ftb@2024(超级管理员)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LogOut, User, KeyRound, Eye, EyeOff, Phone, Mail, Loader2, Check } from 'lucide-react';
|
||||
import { LogOut, User, KeyRound, Eye, EyeOff, Mail, Loader2, Check } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { FieldError } from '@/components/FieldError';
|
||||
@@ -60,9 +60,7 @@ export default function ProfilePage() {
|
||||
{role?.name ?? '-'} <span className="mx-1.5 text-white/40">·</span> {deptName}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-white/70">
|
||||
{user.phone && (
|
||||
<span className="flex items-center gap-1"><Phone className="h-3 w-3" />{user.phone}</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1"><User className="h-3 w-3" />{user.username}</span>
|
||||
{user.email && (
|
||||
<span className="flex items-center gap-1"><Mail className="h-3 w-3" />{user.email}</span>
|
||||
)}
|
||||
@@ -113,9 +111,8 @@ function InfoForm() {
|
||||
const member = members.find((m) => m.id === user.id);
|
||||
|
||||
const [name, setName] = useState(member?.name ?? user.name);
|
||||
const [phone, setPhone] = useState(member?.phone ?? user.phone);
|
||||
const [email, setEmail] = useState(member?.email ?? user.email);
|
||||
const [errors, setErrors] = useState<{ name?: string; phone?: string; email?: string }>({});
|
||||
const [errors, setErrors] = useState<{ name?: string; email?: string }>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [savedAt, setSavedAt] = useState(0);
|
||||
|
||||
@@ -133,7 +130,6 @@ function InfoForm() {
|
||||
|
||||
const handleReset = () => {
|
||||
setName(member?.name ?? user.name);
|
||||
setPhone(member?.phone ?? user.phone);
|
||||
setEmail(member?.email ?? user.email);
|
||||
setErrors({});
|
||||
};
|
||||
@@ -143,8 +139,6 @@ function InfoForm() {
|
||||
const errs: typeof errors = {};
|
||||
if (!name.trim()) errs.name = '请输入姓名';
|
||||
else if (name.trim().length > 20) errs.name = '姓名最长 20 字符';
|
||||
if (!phone.trim()) errs.phone = '请输入手机号';
|
||||
else if (!/^1[3-9]\d{9}$/.test(phone.trim())) errs.phone = '手机号格式错误';
|
||||
if (!email.trim()) errs.email = '请输入邮箱';
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) errs.email = '邮箱格式错误';
|
||||
setErrors(errs);
|
||||
@@ -152,7 +146,7 @@ function InfoForm() {
|
||||
|
||||
setSubmitting(true);
|
||||
setTimeout(() => {
|
||||
updateMember(user.id, { name: name.trim(), phone: phone.trim(), email: email.trim() });
|
||||
updateMember(user.id, { name: name.trim(), email: email.trim() });
|
||||
refreshUser();
|
||||
setSubmitting(false);
|
||||
setSavedAt(Date.now());
|
||||
@@ -168,9 +162,8 @@ function InfoForm() {
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
<FieldError message={errors.name} />
|
||||
</Field>
|
||||
<Field label="手机号" required>
|
||||
<input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="11位手机号" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
<FieldError message={errors.phone} />
|
||||
<Field label="用户名">
|
||||
<input value={member?.username ?? user.username} disabled className="h-9 w-full rounded-lg border border-[var(--line)] bg-zinc-50 px-3 font-mono text-[13px] text-[var(--ink-soft)] cursor-not-allowed" />
|
||||
</Field>
|
||||
<Field label="邮箱" required>
|
||||
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@company.com" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
|
||||
@@ -11,7 +11,7 @@ import { flattenProjects, flattenVersions } from '@/lib/derive';
|
||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
||||
import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement';
|
||||
import { deriveReqDevStatus, canEditRequirement, canCloseRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
|
||||
import { buildRequirementScopeTree, filterRequirementsByScope, type RequirementProductScopeNode, type RequirementScopeSelection } from '@/lib/requirement-scope';
|
||||
import { buildRequirementScopeTree, filterRequirementScopeTreeByKeyword, filterRequirementsByScope, type RequirementProductScopeNode, type RequirementScopeSelection } from '@/lib/requirement-scope';
|
||||
import { sortRequirementsByCreatedAt, type RequirementDateSort } from '@/lib/requirement-sort';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { RequirementModal } from '@/components/requirement/RequirementModal';
|
||||
@@ -173,6 +173,7 @@ function RequirementsPageContent() {
|
||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||
|
||||
const [selectedScope, setSelectedScope] = useState<RequirementScopeSelection>({ type: 'all' });
|
||||
const [scopeSearch, setScopeSearch] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||
@@ -202,6 +203,7 @@ function RequirementsPageContent() {
|
||||
}, [selectedScopeKey]);
|
||||
|
||||
const scopeTree = useMemo(() => buildRequirementScopeTree(overview, requirements), [overview, requirements]);
|
||||
const searchedScopeTree = useMemo(() => filterRequirementScopeTreeByKeyword(scopeTree, scopeSearch), [scopeTree, scopeSearch]);
|
||||
const scopedRequirements = useMemo(() => filterRequirementsByScope(requirements, selectedScope), [requirements, selectedScope]);
|
||||
const scopedVersions = useMemo(() => {
|
||||
if (selectedScope.type === 'product') return allVersions.filter((v) => v.productId === selectedScope.productId);
|
||||
@@ -303,6 +305,18 @@ function RequirementsPageContent() {
|
||||
{requirements.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shrink-0 border-b border-[var(--line)] px-3 py-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
|
||||
<input
|
||||
value={scopeSearch}
|
||||
onChange={(event) => setScopeSearch(event.target.value)}
|
||||
placeholder="搜索产品或项目"
|
||||
aria-label="搜索产品或项目"
|
||||
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[12px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<button
|
||||
onClick={() => setSelectedScope({ type: 'all' })}
|
||||
@@ -316,7 +330,7 @@ function RequirementsPageContent() {
|
||||
<span className="flex-1 text-left">全部需求</span>
|
||||
<ScopeCount count={requirements.length} />
|
||||
</button>
|
||||
{scopeTree.map((product) => (
|
||||
{searchedScopeTree.map((product) => (
|
||||
<RequirementProductNode
|
||||
key={product.id}
|
||||
product={product}
|
||||
@@ -324,6 +338,11 @@ function RequirementsPageContent() {
|
||||
onSelect={setSelectedScope}
|
||||
/>
|
||||
))}
|
||||
{searchedScopeTree.length === 0 && scopeSearch.trim() && (
|
||||
<div className="mt-3 rounded-lg border border-dashed border-[var(--line)] px-3 py-4 text-center text-[12px] text-[var(--ink-muted)]">
|
||||
没有匹配的产品或项目
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -70,18 +70,10 @@ const STARTER_QUESTIONS = getFallbackHelpSuggestions();
|
||||
|
||||
type WenfanConversation = WenfanConversationRecord<ChatMessage>;
|
||||
|
||||
const INITIAL_CONVERSATIONS: WenfanConversation[] = [
|
||||
{ id: 'history-product-create', title: '怎么新建产品?', messages: INITIAL_MESSAGES },
|
||||
{ id: 'history-version-requirements', title: '怎么把需求纳入版本?', messages: INITIAL_MESSAGES },
|
||||
{ id: 'history-dev-task-submit', title: '开发任务怎么提测?', messages: INITIAL_MESSAGES },
|
||||
{ id: 'history-test-bug', title: '测试用例失败后怎么提 Bug?', messages: INITIAL_MESSAGES },
|
||||
{ id: 'history-activity-log', title: '日志记录会记录哪些行为?', messages: INITIAL_MESSAGES },
|
||||
];
|
||||
|
||||
export default function WenfanXiaobaoPage() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(INITIAL_MESSAGES);
|
||||
const [input, setInput] = useState('');
|
||||
const [conversations, setConversations] = useState<WenfanConversation[]>(INITIAL_CONVERSATIONS);
|
||||
const [conversations, setConversations] = useState<WenfanConversation[]>([]);
|
||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
||||
|
||||
const visibleHistory = useMemo(() => conversations.slice(0, 12), [conversations]);
|
||||
|
||||
Reference in New Issue
Block a user