diff --git a/apps/server/prisma/seed-data.spec.ts b/apps/server/prisma/seed-data.spec.ts index 273f178..564b060 100644 --- a/apps/server/prisma/seed-data.spec.ts +++ b/apps/server/prisma/seed-data.spec.ts @@ -76,6 +76,9 @@ describe('clean app data seed', () => { expect.objectContaining({ id: 'm-8', name: '超级管理员', + username: 'admin', + email: '', + departmentId: '', roleId: 'role-admin', isSystem: true, password: 'Ftb@2024', diff --git a/apps/server/prisma/seed-data.ts b/apps/server/prisma/seed-data.ts index 0f6e903..fffdf32 100644 --- a/apps/server/prisma/seed-data.ts +++ b/apps/server/prisma/seed-data.ts @@ -377,13 +377,14 @@ const roles: JsonSeedValue[] = [ const superAdminMember: JsonSeedValue = { id: 'm-8', name: '超级管理员', - email: 'chenshi@company.com', - phone: '13200132008', + username: 'admin', + email: '', + phone: '', roleId: 'role-admin', isSystem: true, password: 'Ftb@2024', createdAt: '2024-01-01', - departmentId: 'dept-1', + departmentId: '', }; const passwordRule: JsonSeedValue = { diff --git a/apps/server/src/modules/data/data.controller.ts b/apps/server/src/modules/data/data.controller.ts index 102d11f..17c23e6 100644 --- a/apps/server/src/modules/data/data.controller.ts +++ b/apps/server/src/modules/data/data.controller.ts @@ -11,7 +11,11 @@ export class DataController { } @Put(':key') - put(@Param('key') key: string, @Body('value') value: unknown) { - return this.dataService.put(key, value); + put( + @Param('key') key: string, + @Body('value') value: unknown, + @Body('version') version?: string | null, + ) { + return this.dataService.put(key, value, version); } } diff --git a/apps/server/src/modules/data/data.service.spec.ts b/apps/server/src/modules/data/data.service.spec.ts index ec7eb1f..a8e9448 100644 --- a/apps/server/src/modules/data/data.service.spec.ts +++ b/apps/server/src/modules/data/data.service.spec.ts @@ -1,11 +1,13 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, ConflictException } from '@nestjs/common'; import { DataService } from './data.service'; describe('DataService', () => { const makeService = () => { const prisma = { appData: { + create: jest.fn(), findUnique: jest.fn(), + updateMany: jest.fn(), upsert: jest.fn(), }, }; @@ -22,6 +24,23 @@ describe('DataService', () => { await expect(service.get('products-overview')).resolves.toEqual({ key: 'products-overview', value: null, + version: null, + }); + }); + + it('returns the AppData version for a stored value', async () => { + const { prisma, service } = makeService(); + const updatedAt = new Date('2026-07-02T08:00:00.000Z'); + prisma.appData.findUnique.mockResolvedValue({ + key: 'products-overview', + value: [{ id: 'p1' }], + updatedAt, + }); + + await expect(service.get('products-overview')).resolves.toEqual({ + key: 'products-overview', + value: [{ id: 'p1' }], + version: updatedAt.toISOString(), }); }); @@ -34,11 +53,13 @@ describe('DataService', () => { it('upserts JSON values for allowed keys', async () => { const { prisma, service } = makeService(); const value = [{ id: 'p1', name: 'Product 1' }]; - prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value }); + const updatedAt = new Date('2026-07-02T08:01:00.000Z'); + prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt }); await expect(service.put('products-overview', value)).resolves.toEqual({ key: 'products-overview', value, + version: updatedAt.toISOString(), }); expect(prisma.appData.upsert).toHaveBeenCalledWith({ where: { key: 'products-overview' }, @@ -51,20 +72,94 @@ describe('DataService', () => { const { prisma, service } = makeService(); const value: unknown[] = []; prisma.appData.upsert.mockImplementation(({ where }) => - Promise.resolve({ key: where.key, value }), + Promise.resolve({ key: where.key, value, updatedAt: new Date('2026-07-02T08:02:00.000Z') }), ); - await expect(service.put('task-worklogs', value)).resolves.toEqual({ - key: 'task-worklogs', - value, - }); - await expect(service.put('work-activities', value)).resolves.toEqual({ - key: 'work-activities', - value, - }); - await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toEqual({ + await expect(service.put('task-worklogs', value)).resolves.toMatchObject({ key: 'task-worklogs', value }); + await expect(service.put('work-activities', value)).resolves.toMatchObject({ key: 'work-activities', value }); + await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toMatchObject({ key: 'overtime', value, }); }); + + it('updates only when the supplied version matches the stored row version', async () => { + const { prisma, service } = makeService(); + const previousVersion = '2026-07-02T08:03:00.000Z'; + const nextUpdatedAt = new Date('2026-07-02T08:04:00.000Z'); + const nextValue = [{ id: 'p2', name: 'Product 2' }]; + prisma.appData.updateMany.mockResolvedValue({ count: 1 }); + prisma.appData.findUnique.mockResolvedValue({ + key: 'products-overview', + value: nextValue, + updatedAt: nextUpdatedAt, + }); + + await expect(service.put('products-overview', nextValue, previousVersion)).resolves.toEqual({ + key: 'products-overview', + value: nextValue, + version: nextUpdatedAt.toISOString(), + }); + expect(prisma.appData.updateMany).toHaveBeenCalledWith({ + where: { + key: 'products-overview', + updatedAt: new Date(previousVersion), + }, + data: { value: nextValue }, + }); + }); + + it('rejects stale AppData versions without overwriting the current value', async () => { + const { prisma, service } = makeService(); + prisma.appData.updateMany.mockResolvedValue({ count: 0 }); + prisma.appData.findUnique.mockResolvedValue({ + key: 'products-overview', + value: [{ id: 'current' }], + updatedAt: new Date('2026-07-02T08:05:00.000Z'), + }); + + await expect( + service.put('products-overview', [{ id: 'stale' }], '2026-07-02T08:03:00.000Z'), + ).rejects.toBeInstanceOf(ConflictException); + expect(prisma.appData.upsert).not.toHaveBeenCalled(); + }); + + it('creates a missing row only when the client loaded a null version', async () => { + const { prisma, service } = makeService(); + const value = [{ id: 'p1' }]; + const updatedAt = new Date('2026-07-02T08:06:00.000Z'); + prisma.appData.create.mockResolvedValue({ key: 'products-overview', value, updatedAt }); + + await expect(service.put('products-overview', value, null)).resolves.toEqual({ + key: 'products-overview', + value, + version: updatedAt.toISOString(), + }); + expect(prisma.appData.create).toHaveBeenCalledWith({ + data: { key: 'products-overview', value }, + }); + expect(prisma.appData.upsert).not.toHaveBeenCalled(); + }); + + it('rejects create-only writes when another client created the row first', async () => { + const { prisma, service } = makeService(); + prisma.appData.create.mockRejectedValue({ code: 'P2002' }); + prisma.appData.findUnique.mockResolvedValue({ + key: 'products-overview', + value: [{ id: 'current' }], + updatedAt: new Date('2026-07-02T08:07:00.000Z'), + }); + + await expect(service.put('products-overview', [{ id: 'new' }], null)).rejects.toBeInstanceOf( + ConflictException, + ); + }); + + it('rejects invalid AppData versions', async () => { + const { service } = makeService(); + + await expect(service.put('products-overview', [], 'not-a-date')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); }); diff --git a/apps/server/src/modules/data/data.service.ts b/apps/server/src/modules/data/data.service.ts index 4b3f83b..6f8e699 100644 --- a/apps/server/src/modules/data/data.service.ts +++ b/apps/server/src/modules/data/data.service.ts @@ -1,8 +1,14 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { isAppDataKey } from './data-keys'; +type AppDataRow = { + key: string; + value: Prisma.JsonValue; + updatedAt: Date; +}; + @Injectable() export class DataService { constructor(private prisma: PrismaService) {} @@ -10,18 +16,48 @@ export class DataService { async get(key: string) { this.ensureAllowedKey(key); const row = await this.prisma.appData.findUnique({ where: { key } }); - return { key, value: row?.value ?? null }; + return this.toResponse(key, row); } - async put(key: string, value: unknown) { + async put(key: string, value: unknown, version?: string | null) { this.ensureAllowedKey(key); const jsonValue = value as Prisma.InputJsonValue; + + if (version === null) { + try { + const row = await this.prisma.appData.create({ + data: { key, value: jsonValue }, + }); + return this.toResponse(key, row); + } catch (error) { + if (this.isUniqueConflict(error)) { + await this.throwConflict(key); + } + throw error; + } + } + + if (version !== undefined) { + const updatedAt = this.parseVersion(version); + const result = await this.prisma.appData.updateMany({ + where: { key, updatedAt }, + data: { value: jsonValue }, + }); + + if (result.count !== 1) { + await this.throwConflict(key); + } + + const row = await this.prisma.appData.findUnique({ where: { key } }); + return this.toResponse(key, row); + } + const row = await this.prisma.appData.upsert({ where: { key }, update: { value: jsonValue }, create: { key, value: jsonValue }, }); - return { key: row.key, value: row.value }; + return this.toResponse(key, row); } private ensureAllowedKey(key: string) { @@ -29,4 +65,41 @@ export class DataService { throw new BadRequestException(`Unsupported data key: ${key}`); } } + + private parseVersion(version: string) { + const updatedAt = new Date(version); + if (!Number.isFinite(updatedAt.getTime())) { + throw new BadRequestException('Invalid AppData version'); + } + return updatedAt; + } + + private toResponse(key: string, row: AppDataRow | null) { + return { + key, + value: row?.value ?? null, + version: row?.updatedAt.toISOString() ?? null, + }; + } + + private async throwConflict(key: string): Promise { + const row = await this.prisma.appData.findUnique({ where: { key } }); + const current = this.toResponse(key, row); + throw new ConflictException({ + code: 'APP_DATA_CONFLICT', + message: 'AppData was changed by another client. Reload before saving again.', + key, + currentValue: current.value, + currentVersion: current.version, + }); + } + + private isUniqueConflict(error: unknown) { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'P2002' + ); + } } diff --git a/apps/web/app/admin/members/page.tsx b/apps/web/app/admin/members/page.tsx index 938c86a..6719a71 100644 --- a/apps/web/app/admin/members/page.tsx +++ b/apps/web/app/admin/members/page.tsx @@ -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(null); + const [nameKeyword, setNameKeyword] = useState(''); const [showMemberModal, setShowMemberModal] = useState(false); const [editingMember, setEditingMember] = useState(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() { {filteredMembers.length}
+
+ + 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" + /> +
diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 14d81b4..8390838 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -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() {
- +
- + 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)]" />
@@ -113,7 +111,7 @@ export default function LoginPage() {

- 默认账号:13200132008 / Ftb@2024(超级管理员) + 默认账号:admin / Ftb@2024(超级管理员)

diff --git a/apps/web/app/profile/page.tsx b/apps/web/app/profile/page.tsx index c9b4129..b1024bd 100644 --- a/apps/web/app/profile/page.tsx +++ b/apps/web/app/profile/page.tsx @@ -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 ?? '-'} · {deptName}

- {user.phone && ( - {user.phone} - )} + {user.username} {user.email && ( {user.email} )} @@ -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() { 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" /> - - 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" /> - + + 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" /> diff --git a/apps/web/app/requirements/page.tsx b/apps/web/app/requirements/page.tsx index 8fce00e..aad5a95 100644 --- a/apps/web/app/requirements/page.tsx +++ b/apps/web/app/requirements/page.tsx @@ -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({ 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}
+
+
+ + 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)]" + /> +
+
- {scopeTree.map((product) => ( + {searchedScopeTree.map((product) => ( ))} + {searchedScopeTree.length === 0 && scopeSearch.trim() && ( +
+ 没有匹配的产品或项目 +
+ )}
diff --git a/apps/web/app/wenfan-xiaobao/page.tsx b/apps/web/app/wenfan-xiaobao/page.tsx index 181963f..c6decbc 100644 --- a/apps/web/app/wenfan-xiaobao/page.tsx +++ b/apps/web/app/wenfan-xiaobao/page.tsx @@ -70,18 +70,10 @@ const STARTER_QUESTIONS = getFallbackHelpSuggestions(); type WenfanConversation = WenfanConversationRecord; -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(INITIAL_MESSAGES); const [input, setInput] = useState(''); - const [conversations, setConversations] = useState(INITIAL_CONVERSATIONS); + const [conversations, setConversations] = useState([]); const [activeConversationId, setActiveConversationId] = useState(null); const visibleHistory = useMemo(() => conversations.slice(0, 12), [conversations]); diff --git a/apps/web/components/requirement/RequirementModal.tsx b/apps/web/components/requirement/RequirementModal.tsx index 1f189b7..c4a9afc 100644 --- a/apps/web/components/requirement/RequirementModal.tsx +++ b/apps/web/components/requirement/RequirementModal.tsx @@ -1,9 +1,9 @@ 'use client'; import { useState, useEffect, useMemo } from 'react'; -import { X } from 'lucide-react'; +import { Search, X } from 'lucide-react'; import type { Requirement, DictItem, SourceType, SourceTarget } from '@/lib/requirement'; import type { Priority } from '@/lib/derive'; -import { SOURCE_TYPE_LABEL, SOURCE_TARGET_LABEL } from '@/lib/requirement'; +import { filterRequirementProjectOptions, filterRequirementVersionOptions, filterSourceTargetsByKeyword, formatSourceTargetSelection, parseSourceTargetSelection, SOURCE_TYPE_LABEL, SOURCE_TARGET_LABEL } from '@/lib/requirement'; interface RequirementModalProps { open: boolean; @@ -50,7 +50,7 @@ export function RequirementModal({ const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const [sourceType, setSourceType] = useState('customer'); - const [sourceTarget, setSourceTarget] = useState(''); + const [selectedSourceTargets, setSelectedSourceTargets] = useState([]); const [productId, setProductId] = useState(''); const [projectId, setProjectId] = useState(''); const [selectedPlatforms, setSelectedPlatforms] = useState([]); @@ -60,16 +60,25 @@ export function RequirementModal({ const [parentId, setParentId] = useState(''); const [versionId, setVersionId] = useState(''); + const [sourceTypeDropOpen, setSourceTypeDropOpen] = useState(false); + const [sourceTargetDropOpen, setSourceTargetDropOpen] = useState(false); + const [sourceTargetSearch, setSourceTargetSearch] = useState(''); + const [productDropOpen, setProductDropOpen] = useState(false); + const [projectDropOpen, setProjectDropOpen] = useState(false); + const [projectSearch, setProjectSearch] = useState(''); + const [versionDropOpen, setVersionDropOpen] = useState(false); + const [versionSearch, setVersionSearch] = useState(''); + const [typeDropOpen, setTypeDropOpen] = useState(false); const [platformDropOpen, setPlatformDropOpen] = useState(false); const filteredProjects = useMemo( - () => productId ? projects.filter((p) => p.productId === productId) : projects, - [projects, productId], + () => filterRequirementProjectOptions(projects, productId, projectSearch), + [projects, productId, projectSearch], ); const filteredVersions = useMemo( - () => projectId ? versions.filter((v) => v.projectId === projectId) : [], - [versions, projectId], + () => filterRequirementVersionOptions(versions, projectId, versionSearch), + [versions, projectId, versionSearch], ); useEffect(() => { @@ -77,7 +86,16 @@ export function RequirementModal({ setTitle(initial.title); setDescription(initial.description || ''); setSourceType(initial.sourceType || 'customer'); - setSourceTarget(initial.sourceTarget || ''); + setSelectedSourceTargets(parseSourceTargetSelection(initial.sourceTarget)); + setSourceTypeDropOpen(false); + setSourceTargetSearch(''); + setSourceTargetDropOpen(false); + setProductDropOpen(false); + setProjectSearch(''); + setProjectDropOpen(false); + setVersionSearch(''); + setVersionDropOpen(false); + setTypeDropOpen(false); setProductId(initial.productId || ''); setProjectId(initial.projectId || ''); setVersionId(initial.versionId || ''); @@ -90,7 +108,16 @@ export function RequirementModal({ setTitle(''); setDescription(''); setSourceType('customer'); - setSourceTarget(''); + setSelectedSourceTargets([]); + setSourceTypeDropOpen(false); + setSourceTargetSearch(''); + setSourceTargetDropOpen(false); + setProductDropOpen(false); + setProjectSearch(''); + setProjectDropOpen(false); + setVersionSearch(''); + setVersionDropOpen(false); + setTypeDropOpen(false); setProductId(''); setProjectId(''); setVersionId(''); @@ -104,7 +131,13 @@ export function RequirementModal({ if (!open) return null; - const filteredTargets = sourceTargets.filter(t => t.sourceType === sourceType); + const filteredTargets = filterSourceTargetsByKeyword(sourceTargets, sourceType, sourceTargetSearch); + + const toggleSourceTarget = (name: string) => { + setSelectedSourceTargets((prev) => + prev.includes(name) ? prev.filter((item) => item !== name) : [...prev, name] + ); + }; const togglePlatform = (id: string) => { setSelectedPlatforms((prev) => @@ -118,7 +151,7 @@ export function RequirementModal({ title: title.trim(), description: description.trim(), sourceType, - sourceTarget: sourceTarget || undefined, + sourceTarget: formatSourceTargetSelection(selectedSourceTargets) || undefined, productId, projectId, versionId: versionId || undefined, @@ -132,6 +165,31 @@ export function RequirementModal({ }); }; + const sourceTargetDisplay = + selectedSourceTargets.length > 0 + ? formatSourceTargetSelection(selectedSourceTargets) + : '请选择'; + + const productDisplay = productId + ? products.find((product) => product.id === productId)?.name ?? '请选择产品' + : '请选择产品'; + + const projectDisplay = projectId + ? projects.find((project) => project.id === projectId)?.name ?? '请选择项目' + : productId + ? '请选择项目' + : '先选择产品'; + + const versionDisplay = versionId + ? versions.find((version) => version.id === versionId)?.name ?? '请选择版本' + : projectId + ? '请选择版本' + : '先选择项目'; + + const typeDisplay = typeId + ? types.find((type) => type.id === typeId)?.name ?? '请选择类型' + : '请选择类型'; + const platformDisplay = selectedPlatforms.length > 0 ? platforms @@ -194,20 +252,37 @@ export function RequirementModal({ - +
+ + {sourceTypeDropOpen && ( +
+ {SOURCE_TYPES.map((item) => ( + + ))} +
+ )} +
@@ -222,18 +297,49 @@ export function RequirementModal({ 管理
- +
+ + {sourceTargetDropOpen && ( +
+
+
+ + setSourceTargetSearch(event.target.value)} + placeholder="搜索" + 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)] outline-none focus:border-[var(--accent)]" + /> +
+
+
+ {filteredTargets.map((target) => ( + + ))} + {filteredTargets.length === 0 && ( +
暂无匹配
+ )} +
+
+ )} +
@@ -243,46 +349,144 @@ export function RequirementModal({ - +
+ + {productDropOpen && ( +
+ {products.map((product) => ( + + ))} + {products.length === 0 && ( +
暂无产品
+ )} +
+ )} +
- +
+ + {projectDropOpen && productId && ( +
+
+
+ + setProjectSearch(event.target.value)} + placeholder="搜索项目" + 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)] outline-none focus:border-[var(--accent)]" + /> +
+
+
+ {filteredProjects.map((project) => ( + + ))} + {filteredProjects.length === 0 && ( +
暂无匹配
+ )} +
+
+ )} +
- +
+ + {versionDropOpen && projectId && ( +
+
+
+ + setVersionSearch(event.target.value)} + placeholder="搜索版本" + 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)] outline-none focus:border-[var(--accent)]" + /> +
+
+
+ {filteredVersions.map((version) => ( + + ))} + {filteredVersions.length === 0 && ( +
暂无匹配
+ )} +
+
+ )} +
@@ -342,18 +546,37 @@ export function RequirementModal({ 管理 - +
+ + {typeDropOpen && ( +
+ {types.map((type) => ( + + ))} + {types.length === 0 && ( +
暂无类型
+ )} +
+ )} +
{/* 7. 优先级 (button group) */} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 8235f93..b8b48e4 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -3,6 +3,25 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v let apiAvailable: boolean | null = null; let probePromise: Promise | null = null; +function getErrorMessage(body: unknown, status: number) { + if (typeof body === 'object' && body !== null && 'message' in body) { + const message = (body as { message?: unknown }).message; + if (typeof message === 'string') return message; + if (Array.isArray(message)) return message.join(', '); + } + return `Request failed: ${status}`; +} + +export class ApiRequestError extends Error { + constructor( + public readonly status: number, + public readonly body: unknown, + ) { + super(getErrorMessage(body, status)); + this.name = 'ApiRequestError'; + } +} + async function checkApi(): Promise { if (apiAvailable !== null) return apiAvailable; if (probePromise) return probePromise; @@ -29,7 +48,7 @@ async function request(path: string, options?: RequestInit): Promise { if (!res) throw new Error('API 不可用'); if (!res.ok) { const error = await res.json().catch(() => ({})); - throw new Error(error.message || `请求失败: ${res.status}`); + throw new ApiRequestError(res.status, error); } return res.json(); } @@ -56,7 +75,7 @@ export const api = { }); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error(err.message || `请求失败: ${res.status}`); + throw new ApiRequestError(res.status, err); } return res.json(); } finally { diff --git a/apps/web/lib/auth-user.test.ts b/apps/web/lib/auth-user.test.ts index 7a06508..266529e 100644 --- a/apps/web/lib/auth-user.test.ts +++ b/apps/web/lib/auth-user.test.ts @@ -1,16 +1,17 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { resolveAuthUserFromMembers, toAuthUser, type AuthUser } from './auth-user'; +import { findMemberByUsernameCredentials, resolveAuthUserFromMembers, toAuthUser, type AuthUser } from './auth-user'; import type { Member } from './members'; const member: Member = { id: 'm-8', name: '超级管理员', - departmentId: 'dept-1', + username: 'admin', + departmentId: '', roleId: 'role-admin', phone: '13200132008', - email: 'admin@company.com', + email: '', password: 'Ftb@2024', createdAt: '2024-01-01', }; @@ -19,10 +20,10 @@ test('toAuthUser removes password and keeps stable member identity', () => { assert.deepEqual(toAuthUser(member), { id: 'm-8', name: '超级管理员', - departmentId: 'dept-1', + username: 'admin', + departmentId: '', roleId: 'role-admin', - phone: '13200132008', - email: 'admin@company.com', + email: '', }); }); @@ -30,9 +31,9 @@ test('resolveAuthUserFromMembers refreshes stale display names by member id', () const staleUser: AuthUser = { id: 'm-8', name: '陈十', - departmentId: 'dept-1', + username: 'chenshi', + departmentId: '', roleId: 'role-admin', - phone: '13200132008', email: 'old@company.com', }; @@ -40,18 +41,26 @@ test('resolveAuthUserFromMembers refreshes stale display names by member id', () assert.equal(resolved?.id, 'm-8'); assert.equal(resolved?.name, '超级管理员'); - assert.equal(resolved?.email, 'admin@company.com'); + assert.equal(resolved?.username, 'admin'); + assert.equal(resolved?.email, ''); }); test('resolveAuthUserFromMembers returns null when the account no longer exists', () => { const staleUser: AuthUser = { id: 'm-9', name: '离职用户', + username: 'lizhiyonghu', departmentId: 'dept-2', roleId: 'role-dev', - phone: '13900139000', email: 'old@company.com', }; assert.equal(resolveAuthUserFromMembers(staleUser, [member]), null); }); + +test('findMemberByUsernameCredentials uses username instead of phone for login', () => { + const matched = findMemberByUsernameCredentials('admin', 'Ftb@2024', [member]); + + assert.equal(matched?.id, 'm-8'); + assert.equal(findMemberByUsernameCredentials('13200132008', 'Ftb@2024', [member]), null); +}); diff --git a/apps/web/lib/auth-user.ts b/apps/web/lib/auth-user.ts index 4eeff39..d8604be 100644 --- a/apps/web/lib/auth-user.ts +++ b/apps/web/lib/auth-user.ts @@ -1,11 +1,12 @@ import type { Member } from './members'; +import { generateUsernameFromName, normalizeUsername } from './members'; export interface AuthUser { id: string; name: string; + username: string; roleId: string; departmentId: string; - phone: string; email: string; } @@ -13,9 +14,9 @@ export function toAuthUser(member: Member): AuthUser { return { id: member.id, name: member.name, + username: normalizeUsername(member.username) || generateUsernameFromName(member.name, []), roleId: member.roleId, departmentId: member.departmentId, - phone: member.phone, email: member.email, }; } @@ -24,3 +25,16 @@ export function resolveAuthUserFromMembers(user: AuthUser, members: Member[]): A const member = members.find((m) => m.id === user.id); return member ? toAuthUser(member) : null; } + +export function findMemberByUsernameCredentials( + username: string, + password: string, + members: Member[], +): Member | null { + const normalizedUsername = normalizeUsername(username); + if (!normalizedUsername || !password) return null; + return members.find((member) => ( + normalizeUsername(member.username) === normalizedUsername && + (member.password || 'Ftb@2024') === password + )) ?? null; +} diff --git a/apps/web/lib/member-list.test.ts b/apps/web/lib/member-list.test.ts new file mode 100644 index 0000000..b685aa9 --- /dev/null +++ b/apps/web/lib/member-list.test.ts @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { filterMembersForList, getDisplayedAccountPassword, resolveLatestEditingMember } from './member-list'; +import type { Department, Member } from './members'; + +const departments: Department[] = [ + { id: 'dept-1', name: '产品部', order: 1, createdAt: '2024-01-01' }, + { id: 'dept-2', name: '技术部', order: 2, createdAt: '2024-01-01' }, + { id: 'dept-2-1', name: '前端组', parentId: 'dept-2', order: 1, createdAt: '2024-01-01' }, +]; + +function member(id: string, name: string, departmentId: string): Member { + return { + id, + name, + username: name.toLowerCase(), + departmentId, + roleId: 'role-dev', + phone: '', + email: '', + password: 'Ftb@2024', + createdAt: '2024-01-01', + }; +} + +test('filterMembersForList matches member name keywords within the selected department tree', () => { + const members = [ + member('m-1', '张三', 'dept-1'), + member('m-2', '李四', 'dept-2-1'), + member('m-3', '李四方', 'dept-2'), + member('m-4', '王五', ''), + ]; + + const result = filterMembersForList({ + members, + departments, + activeDeptId: 'dept-2', + nameKeyword: '李四', + }); + + assert.deepEqual(result.map((item) => item.id), ['m-2', 'm-3']); +}); + +test('filterMembersForList keeps unassigned admin visible only in all members when searching by name', () => { + const members = [ + member('m-8', '超级管理员', ''), + member('m-1', '张三', 'dept-1'), + ]; + + const allResult = filterMembersForList({ + members, + departments, + activeDeptId: null, + nameKeyword: '超级', + }); + const deptResult = filterMembersForList({ + members, + departments, + activeDeptId: 'dept-1', + nameKeyword: '超级', + }); + + assert.deepEqual(allResult.map((item) => item.id), ['m-8']); + assert.deepEqual(deptResult, []); +}); + +test('resolveLatestEditingMember uses the current member record for password display', () => { + const selected = member('m-1', '张三', 'dept-1'); + const latest = { ...selected, password: 'New@2026' }; + + assert.equal(resolveLatestEditingMember(selected, [latest])?.password, 'New@2026'); +}); + +test('getDisplayedAccountPassword shows the latest member password before generated fallback', () => { + assert.equal(getDisplayedAccountPassword(member('m-1', '张三', 'dept-1'), 'Generated@2026'), 'Ftb@2024'); + assert.equal(getDisplayedAccountPassword(null, 'Generated@2026'), 'Generated@2026'); +}); diff --git a/apps/web/lib/member-list.ts b/apps/web/lib/member-list.ts new file mode 100644 index 0000000..46d8b9d --- /dev/null +++ b/apps/web/lib/member-list.ts @@ -0,0 +1,50 @@ +import type { Department, Member } from './members'; + +interface FilterMembersForListInput { + members: Member[]; + departments: Department[]; + activeDeptId: string | null; + nameKeyword: string; +} + +function collectDepartmentTreeIds(departments: Department[], departmentId: string): Set { + const ids = new Set([departmentId]); + let changed = true; + + while (changed) { + changed = false; + for (const department of departments) { + if (department.parentId && ids.has(department.parentId) && !ids.has(department.id)) { + ids.add(department.id); + changed = true; + } + } + } + + return ids; +} + +export function filterMembersForList({ + members, + departments, + activeDeptId, + nameKeyword, +}: FilterMembersForListInput): Member[] { + const keyword = nameKeyword.trim().toLowerCase(); + const visibleDepartmentIds = activeDeptId ? collectDepartmentTreeIds(departments, activeDeptId) : null; + + return members.filter((member) => { + if (visibleDepartmentIds && !visibleDepartmentIds.has(member.departmentId)) return false; + if (!keyword) return true; + return member.name.toLowerCase().includes(keyword); + }); +} + +export function resolveLatestEditingMember(editingMember: Member | null, members: Member[]): Member | null { + if (!editingMember) return null; + return members.find((member) => member.id === editingMember.id) ?? editingMember; +} + +export function getDisplayedAccountPassword(member: Member | null, generatedPassword: string): string { + return member?.password || generatedPassword; +} diff --git a/apps/web/lib/member-system.test.ts b/apps/web/lib/member-system.test.ts index e9c33e1..9e6efbe 100644 --- a/apps/web/lib/member-system.test.ts +++ b/apps/web/lib/member-system.test.ts @@ -11,7 +11,7 @@ import { resolveMemberDisplayName, isMemberReference, } from './member-system'; -import type { Member } from './members'; +import { ensureMemberUsernames, type Member } from './members'; const oldAdmin: Member = { id: SYSTEM_ADMIN_MEMBER_ID, @@ -31,6 +31,9 @@ test('normalizes the built-in admin account when old data named it as a regular assert.equal(result.members.length, 1); assert.equal(result.members[0].id, SYSTEM_ADMIN_MEMBER_ID); assert.equal(result.members[0].name, SYSTEM_ADMIN_MEMBER_NAME); + assert.equal(result.members[0].username, 'admin'); + assert.equal(result.members[0].departmentId, ''); + assert.equal(result.members[0].email, ''); assert.equal(result.members[0].roleId, SYSTEM_ADMIN_ROLE_ID); assert.equal(result.members[0].isSystem, true); assert.equal(result.members[0].phone, oldAdmin.phone); @@ -43,20 +46,48 @@ test('adds a built-in admin account when member data has none', () => { assert.equal(result.members.length, 1); assert.equal(result.members[0].id, SYSTEM_ADMIN_MEMBER_ID); assert.equal(result.members[0].name, SYSTEM_ADMIN_MEMBER_NAME); + assert.equal(result.members[0].username, 'admin'); + assert.equal(result.members[0].departmentId, ''); + assert.equal(result.members[0].email, ''); assert.equal(result.members[0].roleId, SYSTEM_ADMIN_ROLE_ID); }); +test('keeps the built-in admin username when another cached member already uses admin', () => { + const regularMember: Member = { + id: 'm-1', + name: '张三', + username: 'admin', + departmentId: 'dept-1', + roleId: 'role-dev', + phone: '', + email: '', + password: 'Ftb@2024', + createdAt: '2024-01-01', + }; + + const ensured = ensureSystemAdminMember([regularMember, oldAdmin]); + const usernameEnsured = ensureMemberUsernames(ensured.members); + + assert.equal(usernameEnsured.members[0].id, SYSTEM_ADMIN_MEMBER_ID); + assert.equal(usernameEnsured.members[0].username, 'admin'); + assert.equal(usernameEnsured.members[1].username, 'admin1'); +}); + test('protects the built-in admin account from identity and role edits', () => { const patch = sanitizeSystemAdminPatch(SYSTEM_ADMIN_MEMBER_ID, { name: '陈十', + username: 'chenshi', roleId: 'role-dev', departmentId: 'dept-3', phone: '13900139000', + email: 'admin@company.com', }); assert.equal(patch.name, undefined); + assert.equal(patch.username, undefined); assert.equal(patch.roleId, undefined); assert.equal(patch.departmentId, undefined); + assert.equal(patch.email, undefined); assert.equal(patch.phone, '13900139000'); }); diff --git a/apps/web/lib/member-system.ts b/apps/web/lib/member-system.ts index df7ce77..ccc2958 100644 --- a/apps/web/lib/member-system.ts +++ b/apps/web/lib/member-system.ts @@ -3,16 +3,17 @@ import type { Member } from './members'; export const SYSTEM_ADMIN_MEMBER_ID = 'm-8'; export const SYSTEM_ADMIN_MEMBER_NAME = '超级管理员'; export const SYSTEM_ADMIN_ROLE_ID = 'role-admin'; -export const SYSTEM_ADMIN_DEPARTMENT_ID = 'dept-1'; +export const SYSTEM_ADMIN_DEPARTMENT_ID = ''; const LEGACY_SYSTEM_ADMIN_NAMES = ['陈十']; export const SYSTEM_ADMIN_MEMBER: Member = { id: SYSTEM_ADMIN_MEMBER_ID, name: SYSTEM_ADMIN_MEMBER_NAME, + username: 'admin', departmentId: SYSTEM_ADMIN_DEPARTMENT_ID, roleId: SYSTEM_ADMIN_ROLE_ID, phone: '13200132008', - email: 'admin@company.com', + email: '', password: 'Ftb@2024', createdAt: '2024-01-01', isSystem: true, @@ -24,10 +25,11 @@ function normalizeSystemAdminMember(member: Partial): Member { ...member, id: SYSTEM_ADMIN_MEMBER_ID, name: SYSTEM_ADMIN_MEMBER_NAME, + username: SYSTEM_ADMIN_MEMBER.username, departmentId: SYSTEM_ADMIN_DEPARTMENT_ID, roleId: SYSTEM_ADMIN_ROLE_ID, phone: member.phone || SYSTEM_ADMIN_MEMBER.phone, - email: member.email || SYSTEM_ADMIN_MEMBER.email, + email: SYSTEM_ADMIN_MEMBER.email, password: member.password || SYSTEM_ADMIN_MEMBER.password, createdAt: member.createdAt || SYSTEM_ADMIN_MEMBER.createdAt, isSystem: true, @@ -43,7 +45,9 @@ export function ensureSystemAdminMember(members: Member[]): { members: Member[]; const current = members[index]; const normalized = normalizeSystemAdminMember(current); const changed = + index !== 0 || current.name !== normalized.name || + current.username !== normalized.username || current.departmentId !== normalized.departmentId || current.roleId !== normalized.roleId || current.phone !== normalized.phone || @@ -55,14 +59,14 @@ export function ensureSystemAdminMember(members: Member[]): { members: Member[]; if (!changed) return { members, changed: false }; return { - members: members.map((m, i) => (i === index ? normalized : m)), + members: [normalized, ...members.filter((_, i) => i !== index)], changed: true, }; } export function sanitizeSystemAdminPatch(id: string, data: Partial): Partial { if (id !== SYSTEM_ADMIN_MEMBER_ID) return data; - const { name, departmentId, roleId, isSystem, ...allowed } = data; + const { name, username, departmentId, roleId, email, isSystem, ...allowed } = data; return { ...allowed, isSystem: true }; } diff --git a/apps/web/lib/members.test.ts b/apps/web/lib/members.test.ts new file mode 100644 index 0000000..c4f8edb --- /dev/null +++ b/apps/web/lib/members.test.ts @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + generateUsernameFromName, + ensureMemberUsernames, + type Member, +} from './members'; + +function member(overrides: Partial): Member { + return { + id: overrides.id ?? 'm-1', + name: overrides.name ?? '张三', + departmentId: overrides.departmentId ?? 'dept-1', + roleId: overrides.roleId ?? 'role-dev', + phone: overrides.phone ?? '', + email: overrides.email ?? '', + password: overrides.password ?? 'Ftb@2024', + createdAt: overrides.createdAt ?? '2026-07-01', + username: overrides.username, + }; +} + +test('generateUsernameFromName uses lowercase full pinyin for Chinese names', () => { + assert.equal(generateUsernameFromName('张三', []), 'zhangsan'); + assert.equal(generateUsernameFromName(' 欧阳娜娜 ', []), 'ouyangnana'); +}); + +test('generateUsernameFromName appends zero-padded duplicate suffixes', () => { + assert.equal(generateUsernameFromName('张三', ['zhangsan']), 'zhangsan1'); + assert.equal(generateUsernameFromName('张三', ['zhangsan', 'zhangsan1']), 'zhangsan01'); + assert.equal(generateUsernameFromName('张三', ['zhangsan', 'zhangsan1', 'zhangsan01']), 'zhangsan001'); +}); + +test('generateUsernameFromName keeps existing username when editing another member', () => { + assert.equal(generateUsernameFromName('张三', ['zhangsan'], 'lisi'), 'lisi'); +}); + +test('ensureMemberUsernames fills missing usernames without syncing renamed members', () => { + const result = ensureMemberUsernames([ + member({ id: 'm-1', name: '张三', username: 'lisi' }), + member({ id: 'm-2', name: '张三' }), + ]); + + assert.equal(result.changed, true); + assert.equal(result.members[0].username, 'lisi'); + assert.equal(result.members[1].username, 'zhangsan'); +}); diff --git a/apps/web/lib/members.ts b/apps/web/lib/members.ts index 9360da8..9c9df91 100644 --- a/apps/web/lib/members.ts +++ b/apps/web/lib/members.ts @@ -1,3 +1,5 @@ +import { pinyin } from 'pinyin-pro'; + export interface Department { id: string; name: string; @@ -9,6 +11,7 @@ export interface Department { export interface Member { id: string; name: string; + username?: string; departmentId: string; roleId: string; phone: string; @@ -65,3 +68,62 @@ export function maskPhone(phone: string): string { if (!phone || phone.length < 7) return phone; return phone.slice(0, 3) + '****' + phone.slice(7); } + +export function normalizeUsername(username: string | undefined | null): string { + return (username ?? '').trim().replace(/\s+/g, '').toLowerCase(); +} + +function nameToUsernameBase(name: string): string { + const raw = name.trim(); + if (!raw) return 'USER'; + const letters = pinyin(raw, { toneType: 'none', type: 'array' }) + .join('') + .replace(/[^a-zA-Z0-9]/g, '') + .toLowerCase(); + return letters || 'USER'; +} + +function duplicateSuffix(attempt: number): string { + return attempt === 1 ? '1' : `${'0'.repeat(attempt - 1)}1`; +} + +function uniqueUsernameFromBase(base: string, existingUsernames: Iterable): string { + const normalizedBase = normalizeUsername(base) || 'USER'; + const used = new Set(Array.from(existingUsernames, normalizeUsername).filter(Boolean)); + if (!used.has(normalizedBase)) return normalizedBase; + + for (let attempt = 1; attempt < 1000; attempt += 1) { + const candidate = `${normalizedBase}${duplicateSuffix(attempt)}`; + if (!used.has(candidate)) return candidate; + } + + return `${normalizedBase}${Date.now()}`; +} + +export function generateUsernameFromName( + name: string, + existingUsernames: Iterable, + currentUsername?: string, +): string { + const normalizedCurrent = normalizeUsername(currentUsername); + if (normalizedCurrent) return normalizedCurrent; + return uniqueUsernameFromBase(nameToUsernameBase(name), existingUsernames); +} + +export function ensureMemberUsernames(members: Member[]): { members: Member[]; changed: boolean } { + const used = new Set(); + let changed = false; + const normalizedMembers = members.map((member) => { + const requested = normalizeUsername(member.username); + const username = requested + ? uniqueUsernameFromBase(requested, used) + : generateUsernameFromName(member.name, used); + + used.add(username); + if (member.username === username) return member; + changed = true; + return { ...member, username }; + }); + + return { members: normalizedMembers, changed }; +} diff --git a/apps/web/lib/overtime.test.ts b/apps/web/lib/overtime.test.ts index f8c2157..9b6a7bf 100644 --- a/apps/web/lib/overtime.test.ts +++ b/apps/web/lib/overtime.test.ts @@ -54,6 +54,7 @@ function member(id: string, name: string, departmentId: string, roleId: string): return { id, name, + username: id.toUpperCase(), departmentId, roleId, phone: '', @@ -89,9 +90,9 @@ function toAuthUser(member: Member): AuthUser { return { id: member.id, name: member.name, + username: member.username ?? member.id.toUpperCase(), roleId: member.roleId, departmentId: member.departmentId, - phone: member.phone, email: member.email, }; } diff --git a/apps/web/lib/requirement-form-options.test.ts b/apps/web/lib/requirement-form-options.test.ts new file mode 100644 index 0000000..fe9beb1 --- /dev/null +++ b/apps/web/lib/requirement-form-options.test.ts @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + filterRequirementProjectOptions, + filterRequirementVersionOptions, +} from './requirement'; + +const projects = [ + { id: 'project-1', name: 'Mobile App', productId: 'product-1' }, + { id: 'project-2', name: 'Admin Portal', productId: 'product-1' }, + { id: 'project-3', name: 'Data Console', productId: 'product-2' }, +]; + +const versions = [ + { id: 'version-1', name: 'V1.0 Checkout', projectId: 'project-1' }, + { id: 'version-2', name: 'V1.1 Profile', projectId: 'project-1' }, + { id: 'version-3', name: 'V2.0 Admin', projectId: 'project-2' }, +]; + +test('filters requirement project options by selected product and keyword', () => { + assert.deepEqual(filterRequirementProjectOptions(projects, '', '').map((project) => project.id), []); + assert.deepEqual(filterRequirementProjectOptions(projects, 'product-1', '').map((project) => project.id), [ + 'project-1', + 'project-2', + ]); + assert.deepEqual(filterRequirementProjectOptions(projects, 'product-1', 'portal').map((project) => project.id), [ + 'project-2', + ]); +}); + +test('filters requirement version options by selected project and keyword', () => { + assert.deepEqual(filterRequirementVersionOptions(versions, '', '').map((version) => version.id), []); + assert.deepEqual(filterRequirementVersionOptions(versions, 'project-1', 'v1').map((version) => version.id), [ + 'version-1', + 'version-2', + ]); + assert.deepEqual(filterRequirementVersionOptions(versions, 'project-2', 'admin').map((version) => version.id), [ + 'version-3', + ]); +}); diff --git a/apps/web/lib/requirement-scope.test.ts b/apps/web/lib/requirement-scope.test.ts index b9deef5..18294c4 100644 --- a/apps/web/lib/requirement-scope.test.ts +++ b/apps/web/lib/requirement-scope.test.ts @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import type { Requirement } from './requirement'; import { buildRequirementScopeTree, + filterRequirementScopeTreeByKeyword, filterRequirementsByScope, type RequirementScopeSelection, } from './requirement-scope'; @@ -84,3 +85,21 @@ test('builds product and project tree counts from requirements', () => { assert.equal(tree[0].projects[1].count.total, 1); assert.equal(tree[1].count.total, 1); }); + +test('filters requirement scope tree by product or project keyword', () => { + const tree = buildRequirementScopeTree(overview, [ + req('req-1', 'product-1', 'project-1', 'pending_review'), + req('req-2', 'product-1', 'project-2', 'developing'), + req('req-3', 'product-2', 'project-3', 'closed'), + ]); + + const productMatched = filterRequirementScopeTreeByKeyword(tree, 'crm'); + assert.deepEqual(productMatched.map((product) => product.id), ['product-1']); + assert.deepEqual(productMatched[0].projects.map((project) => project.id), ['project-1', 'project-2']); + + const projectMatched = filterRequirementScopeTreeByKeyword(tree, 'admin'); + assert.deepEqual(projectMatched.map((product) => product.id), ['product-1']); + assert.deepEqual(projectMatched[0].projects.map((project) => project.id), ['project-2']); + + assert.equal(filterRequirementScopeTreeByKeyword(tree, 'missing').length, 0); +}); diff --git a/apps/web/lib/requirement-scope.ts b/apps/web/lib/requirement-scope.ts index 962e61d..818a481 100644 --- a/apps/web/lib/requirement-scope.ts +++ b/apps/web/lib/requirement-scope.ts @@ -58,6 +58,29 @@ export function buildRequirementScopeTree( }); } +export function filterRequirementScopeTreeByKeyword( + tree: RequirementProductScopeNode[], + keyword: string, +): RequirementProductScopeNode[] { + const query = normalizeScopeKeyword(keyword); + if (!query) return tree; + + const filtered: RequirementProductScopeNode[] = []; + for (const product of tree) { + if (normalizeScopeKeyword(product.name).includes(query)) { + filtered.push(product); + continue; + } + + const projects = product.projects.filter((project) => normalizeScopeKeyword(project.name).includes(query)); + if (projects.length > 0) { + filtered.push({ ...product, projects }); + } + } + + return filtered; +} + function countRequirements(requirements: Requirement[]): RequirementScopeCount { const byStatus: Partial> = {}; for (const requirement of requirements) { @@ -68,3 +91,7 @@ function countRequirements(requirements: Requirement[]): RequirementScopeCount { byStatus, }; } + +function normalizeScopeKeyword(value: string): string { + return value.trim().toLowerCase(); +} diff --git a/apps/web/lib/requirement-source-target.test.ts b/apps/web/lib/requirement-source-target.test.ts new file mode 100644 index 0000000..55ce72a --- /dev/null +++ b/apps/web/lib/requirement-source-target.test.ts @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import type { SourceTarget } from './requirement'; +import { + filterSourceTargetsByKeyword, + formatSourceTargetSelection, + parseSourceTargetSelection, +} from './requirement'; + +const targets: SourceTarget[] = [ + { id: 'st-1', name: 'Alpha Bank', sourceType: 'customer', createdAt: '2026-07-01' }, + { id: 'st-2', name: 'Beta Retail', sourceType: 'customer', createdAt: '2026-07-01' }, + { id: 'st-3', name: 'Product Team', sourceType: 'internal', createdAt: '2026-07-01' }, +]; + +test('parses and formats requirement source target multi-selection', () => { + assert.deepEqual(parseSourceTargetSelection('Alpha Bank、 Beta Retail, Product Team,Alpha Bank'), [ + 'Alpha Bank', + 'Beta Retail', + 'Product Team', + ]); + assert.equal(formatSourceTargetSelection(['Alpha Bank', '', 'Beta Retail', 'Alpha Bank']), 'Alpha Bank、Beta Retail'); +}); + +test('filters source targets by source type and keyword', () => { + assert.deepEqual( + filterSourceTargetsByKeyword(targets, 'customer', 'bank').map((target) => target.id), + ['st-1'], + ); + assert.deepEqual( + filterSourceTargetsByKeyword(targets, 'internal', '').map((target) => target.id), + ['st-3'], + ); +}); diff --git a/apps/web/lib/requirement.ts b/apps/web/lib/requirement.ts index 7afa210..27e371b 100644 --- a/apps/web/lib/requirement.ts +++ b/apps/web/lib/requirement.ts @@ -17,6 +17,80 @@ export interface SourceTarget { createdAt: string; } +export function parseSourceTargetSelection(value?: string | null): string[] { + if (!value) return []; + return uniqueNonEmptyNames(value.split(/[、,,]/)); +} + +export function formatSourceTargetSelection(names: string[]): string { + return uniqueNonEmptyNames(names).join('、'); +} + +export function filterSourceTargetsByKeyword( + targets: SourceTarget[], + sourceType: SourceType, + keyword: string, +): SourceTarget[] { + const query = keyword.trim().toLowerCase(); + return targets.filter((target) => { + if (target.sourceType !== sourceType) return false; + if (!query) return true; + return target.name.toLowerCase().includes(query); + }); +} + +export interface RequirementProjectOption { + id: string; + name: string; + productId: string; +} + +export interface RequirementVersionOption { + id: string; + name: string; + projectId: string; +} + +export function filterRequirementProjectOptions( + projects: RequirementProjectOption[], + productId: string, + keyword: string, +): RequirementProjectOption[] { + if (!productId) return []; + const query = keyword.trim().toLowerCase(); + return projects.filter((project) => { + if (project.productId !== productId) return false; + if (!query) return true; + return project.name.toLowerCase().includes(query); + }); +} + +export function filterRequirementVersionOptions( + versions: RequirementVersionOption[], + projectId: string, + keyword: string, +): RequirementVersionOption[] { + if (!projectId) return []; + const query = keyword.trim().toLowerCase(); + return versions.filter((version) => { + if (version.projectId !== projectId) return false; + if (!query) return true; + return version.name.toLowerCase().includes(query); + }); +} + +function uniqueNonEmptyNames(names: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const name of names) { + const trimmed = name.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + return out; +} + export interface Requirement { id: string; code: string; diff --git a/apps/web/lib/server-data.test.ts b/apps/web/lib/server-data.test.ts new file mode 100644 index 0000000..ab5f1b4 --- /dev/null +++ b/apps/web/lib/server-data.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { loadServerData, saveServerData, ServerDataConflictError } from './server-data'; + +type MockResponse = { + status: number; + body: unknown; +}; + +test('server data saves include the latest loaded AppData version', async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; method: string; body?: unknown }> = []; + const responses: MockResponse[] = [ + { status: 200, body: {} }, + { + status: 200, + body: { key: 'dev-tasks', value: [{ id: 'old' }], version: 'version-1' }, + }, + { + status: 200, + body: { key: 'dev-tasks', value: [{ id: 'next' }], version: 'version-2' }, + }, + { + status: 200, + body: { key: 'dev-tasks', value: [{ id: 'again' }], version: 'version-3' }, + }, + { + status: 200, + body: { key: 'bugs', value: [], version: 'bugs-version-1' }, + }, + { + status: 409, + body: { + code: 'APP_DATA_CONFLICT', + key: 'dev-tasks', + currentValue: [{ id: 'server-copy' }], + currentVersion: 'version-4', + }, + }, + ]; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const response = responses.shift(); + if (!response) throw new Error('Unexpected fetch call'); + calls.push({ + url: String(input), + method: init?.method ?? 'GET', + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }); + return new Response(JSON.stringify(response.body), { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + try { + await assert.doesNotReject(async () => { + const loaded = await loadServerData>('dev-tasks'); + assert.deepEqual(loaded, [{ id: 'old' }]); + }); + + await saveServerData('dev-tasks', [{ id: 'next' }]); + assert.deepEqual(calls[2].body, { + value: [{ id: 'next' }], + version: 'version-1', + }); + + await saveServerData('dev-tasks', [{ id: 'again' }]); + assert.deepEqual(calls[3].body, { + value: [{ id: 'again' }], + version: 'version-2', + }); + + await saveServerData('bugs', []); + assert.deepEqual(calls[4].body, { value: [] }); + + await assert.rejects( + () => saveServerData('dev-tasks', [{ id: 'stale' }]), + (error: unknown) => { + if (!(error instanceof ServerDataConflictError)) return false; + return error.key === 'dev-tasks' && error.currentVersion === 'version-4'; + }, + ); + assert.deepEqual(calls[5].body, { + value: [{ id: 'stale' }], + version: 'version-3', + }); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/apps/web/lib/server-data.ts b/apps/web/lib/server-data.ts index 80ad322..810ebdf 100644 --- a/apps/web/lib/server-data.ts +++ b/apps/web/lib/server-data.ts @@ -1,4 +1,4 @@ -import { api } from './api'; +import { api, ApiRequestError } from './api'; export type ServerDataKey = | 'products-overview' @@ -19,13 +19,72 @@ export type ServerDataKey = interface ServerDataResponse { key: ServerDataKey; value: T | null; + version: string | null; +} + +type ServerDataConflictBody = { + code: 'APP_DATA_CONFLICT'; + key: ServerDataKey; + currentValue: T | null; + currentVersion: string | null; +}; + +const serverDataVersions = new Map(); + +export class ServerDataConflictError extends Error { + constructor( + public readonly key: ServerDataKey, + public readonly currentValue: T | null, + public readonly currentVersion: string | null, + ) { + super('Server data was changed by another user. Reload before saving again.'); + this.name = 'ServerDataConflictError'; + } } export async function loadServerData(key: ServerDataKey): Promise { const res = await api.get>(`/data/${key}`); + serverDataVersions.set(key, res.version ?? null); return res.value; } export async function saveServerData(key: ServerDataKey, value: T): Promise { - await api.put(`/data/${key}`, { value }); + const payload: { value: T; version?: string | null } = { value }; + if (serverDataVersions.has(key)) { + payload.version = serverDataVersions.get(key) ?? null; + } + + try { + const res = await api.put>(`/data/${key}`, payload); + serverDataVersions.set(key, res.version ?? null); + } catch (error) { + if ( + error instanceof ApiRequestError && + error.status === 409 && + isServerDataConflictBody(error.body, key) + ) { + throw new ServerDataConflictError( + error.body.key, + error.body.currentValue, + error.body.currentVersion, + ); + } + throw error; + } +} + +function isServerDataConflictBody( + body: unknown, + key: ServerDataKey, +): body is ServerDataConflictBody { + return ( + typeof body === 'object' && + body !== null && + (body as { code?: unknown }).code === 'APP_DATA_CONFLICT' && + (body as { key?: unknown }).key === key && + ('currentVersion' in body + ? typeof (body as { currentVersion?: unknown }).currentVersion === 'string' || + (body as { currentVersion?: unknown }).currentVersion === null + : true) + ); } diff --git a/apps/web/lib/wenfan-xiaobao-ui.test.ts b/apps/web/lib/wenfan-xiaobao-ui.test.ts index 4949ca4..d3eaab1 100644 --- a/apps/web/lib/wenfan-xiaobao-ui.test.ts +++ b/apps/web/lib/wenfan-xiaobao-ui.test.ts @@ -54,6 +54,18 @@ test('wenfan xiaobao page provides records, chat, and voice input surfaces', () assert.match(page, /: 'bg-white hover:bg-\[var\(--bg-subtle\)\]'/); }); +test('wenfan xiaobao page does not ship seeded conversation history', () => { + const page = readFileSync(join(process.cwd(), 'app/wenfan-xiaobao/page.tsx'), 'utf8'); + + assert.match(page, /useState\(\[\]\)/); + assert.doesNotMatch(page, /INITIAL_CONVERSATIONS/); + assert.doesNotMatch(page, /history-product-create/); + assert.doesNotMatch(page, /history-version-requirements/); + assert.doesNotMatch(page, /history-dev-task-submit/); + assert.doesNotMatch(page, /history-test-bug/); + assert.doesNotMatch(page, /history-activity-log/); +}); + test('wenfan help screenshot script targets version detail tabs and failed test case bug entry', () => { const script = readFileSync(join(process.cwd(), 'scripts/capture-wenfan-help-screenshots.mjs'), 'utf8'); diff --git a/apps/web/package.json b/apps/web/package.json index 965e7bc..bfcaf0b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@ftb/shared": "workspace:*", "lucide-react": "^1.17.0", "next": "^14.2.0", + "pinyin-pro": "^3.28.1", "react": "^18.3.0", "react-dom": "^18.3.0", "zustand": "^4.5.0" diff --git a/apps/web/scripts/capture-wenfan-help-screenshots.mjs b/apps/web/scripts/capture-wenfan-help-screenshots.mjs index 1217347..506876c 100644 --- a/apps/web/scripts/capture-wenfan-help-screenshots.mjs +++ b/apps/web/scripts/capture-wenfan-help-screenshots.mjs @@ -11,10 +11,10 @@ const OUTPUT_DIR = resolve('apps/web/public/help/wenfan-xiaobao'); const AUTH_USER = { id: 'm-8', name: '超级管理员', + username: 'admin', roleId: 'role-admin', - departmentId: 'dept-1', - phone: '13200132008', - email: 'admin@company.com', + departmentId: '', + email: '', }; const versionDetailRoute = (context) => (context.versionId ? `/versions/${context.versionId}` : '/versions'); diff --git a/apps/web/stores/useAuthStore.ts b/apps/web/stores/useAuthStore.ts index 9ca0f9b..8bab260 100644 --- a/apps/web/stores/useAuthStore.ts +++ b/apps/web/stores/useAuthStore.ts @@ -3,14 +3,15 @@ import { create } from 'zustand'; import { loadServerData } from '@/lib/server-data'; import type { Member } from '@/lib/members'; +import { ensureMemberUsernames } from '@/lib/members'; import { SYSTEM_ADMIN_MEMBER, ensureSystemAdminMember } from '@/lib/member-system'; -import { resolveAuthUserFromMembers, toAuthUser, type AuthUser } from '@/lib/auth-user'; +import { findMemberByUsernameCredentials, resolveAuthUserFromMembers, toAuthUser, type AuthUser } from '@/lib/auth-user'; import { useMemberStore } from './useMemberStore'; interface AuthState { user: AuthUser | null; isAuthenticated: boolean; - login: (phone: string, password: string, remember: boolean) => Promise; + login: (username: string, password: string, remember: boolean) => Promise; logout: () => void; checkAuth: () => void; refreshUser: () => Promise; @@ -40,11 +41,15 @@ function clearAuthStorage() { async function loadMembersForAuth(): Promise { try { const stored = await loadServerData('members'); - if (stored?.members?.length) return ensureSystemAdminMember(stored.members).members; + if (stored?.members?.length) { + const ensured = ensureSystemAdminMember(stored.members); + return ensureMemberUsernames(ensured.members).members; + } } catch {} const inMemoryMembers = useMemberStore.getState().members; - return inMemoryMembers.length > 0 ? ensureSystemAdminMember(inMemoryMembers).members : FALLBACK_MEMBERS; + const members = inMemoryMembers.length > 0 ? ensureSystemAdminMember(inMemoryMembers).members : FALLBACK_MEMBERS; + return ensureMemberUsernames(members).members; } async function refreshStoredAuthUser(user: AuthUser, remember: boolean, set: (state: Partial) => void) { @@ -65,9 +70,9 @@ export const useAuthStore = create((set) => ({ user: null, isAuthenticated: false, - login: async (phone, password, remember) => { + login: async (username, password, remember) => { const members = await loadMembersForAuth(); - const member = members.find((m) => m.phone === phone && (m.password || 'Ftb@2024') === password); + const member = findMemberByUsernameCredentials(username, password, members); if (!member) return false; const user = toAuthUser(member); diff --git a/apps/web/stores/useMemberStore.ts b/apps/web/stores/useMemberStore.ts index 0068957..97144ab 100644 --- a/apps/web/stores/useMemberStore.ts +++ b/apps/web/stores/useMemberStore.ts @@ -1,7 +1,7 @@ 'use client'; import { create } from 'zustand'; import type { Department, Member, RoleItem, PasswordRule } from '@/lib/members'; -import { DEFAULT_PASSWORD_RULE, generatePassword } from '@/lib/members'; +import { DEFAULT_PASSWORD_RULE, ensureMemberUsernames, generatePassword, generateUsernameFromName } from '@/lib/members'; import { SYSTEM_ADMIN_MEMBER, SYSTEM_ADMIN_ROLE_ID, @@ -87,14 +87,15 @@ export const useMemberStore = create((set, get) => ({ roles.length !== rolesWithNewDefaults.length || roles.some((role, index) => JSON.stringify(role) !== JSON.stringify(rolesWithNewDefaults[index])); const ensured = ensureSystemAdminMember(cached.members); + const usernameEnsured = ensureMemberUsernames(ensured.members); const next = { departments: cached.departments, - members: ensured.members, + members: usernameEnsured.members, roles, passwordRule: cached.passwordRule || DEFAULT_PASSWORD_RULE, }; set(next); - if (ensured.changed || rolesChanged) saveStored(next); + if (ensured.changed || usernameEnsured.changed || rolesChanged) saveStored(next); } }, @@ -121,7 +122,13 @@ export const useMemberStore = create((set, get) => ({ }, createMember: (data) => { - const member: Member = { ...data, id: `m-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) }; + const member: Member = { + ...data, + username: data.username || generateUsernameFromName(data.name, get().members.map((m) => m.username ?? '')), + phone: data.phone ?? '', + id: `m-${Date.now()}`, + createdAt: new Date().toISOString().slice(0, 10), + }; const members = [...get().members, member]; set({ members }); saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }); diff --git a/docs/architecture.md b/docs/architecture.md index b0afbd5..b990a8e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,6 +117,7 @@ DevTask 没有"已完成"状态,"已提测"就是终态——开发交付完 **V2.1(当前):** 通用服务端文档表 `app_data` - 后端:`apps/server/src/modules/data/` 提供 `GET/PUT /api/v1/data/:key` - 数据库:Prisma `AppData` 模型,表名 `app_data`,`key` 为主键,`value` 为 JSONB +- 一致性:`GET` 返回 `updatedAt` 派生的 `version`;前端保存时带上最近读取的 `version`,后端用 `key + updatedAt` 原子更新,版本不匹配返回 `409 APP_DATA_CONFLICT` - 前端:各 Zustand store 保持现有数据形状,通过 `apps/web/lib/server-data.ts` 读写服务端 - 覆盖范围:产品/项目/版本树、需求池、调研/产品方案/UI 计划、开发任务、测试用例、Bug、成员/角色/部门、任务类型、任务工时日志、加班记录 - 浏览器仅保留登录会话(`ftb_auth_session` / `ftb_auth_persist`),不再作为业务数据主存储 diff --git a/docs/decisions.md b/docs/decisions.md index 24f139e..7be284f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -476,3 +476,17 @@ - HTTPS 先交给云负载均衡、CDN、宿主机证书工具或外层 Nginx 终止;Compose 内置 Nginx 保持 HTTP 反代基线。 **理由**:Docker Compose 足够覆盖当前单机云服务器和本地服务器形态,部署成本低、可读性强,也符合现阶段 2 核 4G 云主机目标。同域反代能减少 CORS 和公网端口暴露面。本地服务器默认 8080,避免占用 80 端口或要求管理员权限;云服务器继续使用 80 作为外层入口。HTTPS 证书自动续期和域名接入在不同云环境差异较大,先作为外层能力处理,避免把生产部署模板绑死在某一种证书方案上。 + +## 39. AppData 文档写入采用乐观锁,先阻止静默覆盖 + +**问题**:V2.1 阶段业务数据仍按模块存成 `app_data.value` 整份 JSON 文档。多人同时打开同一模块后,如果 A 和 B 都基于旧副本编辑,原来的无条件 `upsert` 会让后保存的人覆盖先保存的人,尤其是任务、Bug、成员等高频写入数据。 + +**决策**: +- `GET /api/v1/data/:key` 返回 `version`,由 `AppData.updatedAt.toISOString()` 派生;空文档返回 `version: null`。 +- 前端 `server-data.ts` 在读取和保存成功后缓存每个 key 的最新 `version`。 +- 前端保存已读取过的 key 时提交 `{ value, version }`;尚未读取过的兼容路径仍可提交 `{ value }` 走旧式 upsert。 +- 后端收到字符串 `version` 时使用 `updateMany({ where: { key, updatedAt }, data: { value } })` 原子比较并更新;`count !== 1` 返回 `409 APP_DATA_CONFLICT`。 +- 后端收到 `version: null` 时只允许创建不存在的行;如果其他客户端已经创建,返回同样的 `409 APP_DATA_CONFLICT`。 +- 冲突响应返回 `currentVersion` 和 `currentValue`,但当前前端不自动合并、不自动重试,避免把旧本地副本用新版本号再次覆盖服务端数据。 + +**理由**:这是 AppData 阶段成本最低、收益最高的一致性补强。它不能提供字段级协同编辑,但能阻止最危险的“静默最后写入覆盖”。后续拆成关系表和领域 API 后,再在具体实体上做更细粒度的事务、唯一约束、审计日志和冲突合并 UI。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 04d3f79..b346680 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,6 +6,11 @@ ### 已完成(按时间倒序) +**2026-07-02** +- `app_data` 读写增加乐观锁版本:`GET` 返回 `version`,前端保存携带最近版本,后端用 `key + updatedAt` 原子更新 +- stale version / create race 返回 `409 APP_DATA_CONFLICT`,阻止多人同时编辑时的静默覆盖 +- 新增后端 AppData 并发写入回归测试和前端 `server-data` 版本缓存测试 + **2026-07-01** - 补齐云服务器生产部署基线:`Dockerfile.web`、`Dockerfile.server`、`docker-compose.prod.yml`、Nginx 反代模板和 `.env.production.example` - 补齐本地服务器/局域网部署基线:`docker-compose.local.yml`、`.env.local-server.example`、`deploy:local:*` 脚本 diff --git a/docs/workflow.md b/docs/workflow.md index f1aa497..940d467 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -135,6 +135,13 @@ - 涉及 UI 改动:`curl http://localhost:3000/` 检查 200 - 不会自动跑 dev server,假定它已经运行 +## AppData 并发写入流程 + +- 前端通过 `loadServerData(key)` 读取业务文档时,必须缓存响应里的 `version`。 +- 前端通过 `saveServerData(key, value)` 保存已读取过的 key 时,必须把最近一次成功读取/保存得到的 `version` 一起提交。 +- 后端只在 `key + updatedAt(version)` 匹配时更新;如果其他用户已经先保存,返回 `409 APP_DATA_CONFLICT`,响应包含当前服务端 `currentVersion` 和 `currentValue`。 +- 收到 `ServerDataConflictError` 时,不要自动重试覆盖。当前处理策略是阻止静默覆盖,后续 UI 冲突合并能力再单独补。 + ## 与我相关(Workspace)数据流 ``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 237de02..c09eca0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: next: specifier: ^14.2.0 version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + pinyin-pro: + specifier: ^3.28.1 + version: 3.28.1 react: specifier: ^18.3.0 version: 18.3.1 @@ -2285,6 +2288,9 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} + pinyin-pro@3.28.1: + resolution: {integrity: sha512-oqz8ulwRgtUXRi0vbqEfGNly19zpyCxYrjhkk5TibGcgSW6eNwS5woajCXRwqURi8Ehc2yOFTiB4uNoZ+NJOnA==} + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -5544,6 +5550,8 @@ snapshots: pify@2.3.0: {} + pinyin-pro@3.28.1: {} + pirates@4.0.7: {} pkg-dir@4.2.0: