feat(问翻小宝): 增加静态入口页面
关键改动: - 新增问翻小宝页面,包含历史记录、对话和语音输入区域 - 在侧边栏加入独立入口并放在小宝预警之前 - 增加页面结构和导航顺序测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
187
apps/web/app/wenfan-xiaobao/page.tsx
Normal file
187
apps/web/app/wenfan-xiaobao/page.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BotMessageSquare,
|
||||||
|
MessageCircleQuestionMark,
|
||||||
|
Mic,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
SendHorizontal,
|
||||||
|
SquarePen,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const HISTORY_ITEMS = [
|
||||||
|
{ title: '如何从产品创建到版本发布?', time: '今天' },
|
||||||
|
{ title: '需求池的状态分别代表什么?', time: '今天' },
|
||||||
|
{ title: '成员离职后怎么转交任务?', time: '昨天' },
|
||||||
|
{ title: 'AI 配置和模型在哪里维护?', time: '昨天' },
|
||||||
|
{ title: '测试用例失败后怎么提 Bug?', time: '周一' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const QUICK_QUESTIONS = [
|
||||||
|
'怎么创建一个版本?',
|
||||||
|
'怎么把需求关联到项目?',
|
||||||
|
'小宝预警的红点是什么意思?',
|
||||||
|
];
|
||||||
|
|
||||||
|
const MESSAGES = [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
title: '问翻小宝',
|
||||||
|
content: '我是 FTB 的系统帮助助手。你可以直接问产品、项目、版本、需求、任务、测试、Bug、权限和 AI 配置相关的使用问题。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
title: '我',
|
||||||
|
content: '怎么把需求放进某个版本里?',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
title: '问翻小宝',
|
||||||
|
content: '进入版本详情后,在需求区域选择当前项目下已采纳的需求;保存后,版本会基于这些需求继续承载开发任务、测试用例和发布风险分析。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function WenfanXiaobaoPage() {
|
||||||
|
return (
|
||||||
|
<div className="h-full bg-white text-[#171717]">
|
||||||
|
<main className="grid h-full min-h-0 grid-cols-1 overflow-hidden lg:grid-cols-[300px_minmax(0,1fr)]">
|
||||||
|
<aside className="hidden min-h-0 flex-col border-r border-[#e5e5e5] bg-white lg:flex">
|
||||||
|
<div className="space-y-3 p-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-10 w-full items-center gap-2 rounded-lg px-3 text-left text-[14px] font-medium text-[#171717] hover:bg-white"
|
||||||
|
>
|
||||||
|
<SquarePen className="h-4 w-4" strokeWidth={1.9} />
|
||||||
|
新对话
|
||||||
|
</button>
|
||||||
|
<label className="flex h-9 items-center gap-2 rounded-lg bg-white px-3 text-[#8a8a8a] ring-1 ring-[#e5e5e5]">
|
||||||
|
<Search className="h-4 w-4 shrink-0" strokeWidth={1.8} />
|
||||||
|
<input
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-[13px] text-[#171717] outline-none placeholder:text-[#8a8a8a]"
|
||||||
|
placeholder="搜索历史记录"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||||
|
<p className="px-3 py-2 text-[12px] font-medium text-[#6b6b6b]">历史记录</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{HISTORY_ITEMS.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={item.title}
|
||||||
|
type="button"
|
||||||
|
className={`w-full rounded-lg p-3 text-left transition-colors ${
|
||||||
|
index === 0
|
||||||
|
? 'border border-[var(--accent)] bg-[var(--accent-soft)]'
|
||||||
|
: 'bg-white hover:bg-[var(--bg-subtle)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="truncate text-[13px] leading-5 text-[#202123]">{item.title}</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-[#8a8a8a]">{item.time}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="flex min-h-0 flex-col bg-white">
|
||||||
|
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[#ececec] px-5">
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#f2f2f2] text-[#171717]">
|
||||||
|
<MessageCircleQuestionMark className="h-4 w-4" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-[15px] font-semibold text-[#171717]">问翻小宝</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-[#f4f4f4] px-3 py-1 text-[12px] text-[#6b6b6b]">对话</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-7 px-6 py-8">
|
||||||
|
{MESSAGES.map((message) => (
|
||||||
|
<MessageRow key={`${message.title}-${message.content}`} message={message} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
{QUICK_QUESTIONS.map((question) => (
|
||||||
|
<button
|
||||||
|
key={question}
|
||||||
|
type="button"
|
||||||
|
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
|
||||||
|
>
|
||||||
|
{question}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="shrink-0 bg-white px-6 pb-6 pt-2">
|
||||||
|
<div className="mx-auto w-full max-w-3xl">
|
||||||
|
<div className="rounded-[28px] border border-[#d9d9e3] bg-white shadow-[0_8px_28px_rgba(0,0,0,0.08)] focus-within:border-[#b9b9c6]">
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
className="max-h-32 min-h-14 w-full resize-none bg-transparent px-5 pt-4 text-[14px] leading-6 text-[#171717] outline-none placeholder:text-[#8a8a8a]"
|
||||||
|
placeholder="输入系统使用问题,或点击语音按钮"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between px-3 pb-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full text-[#6b6b6b] hover:bg-[#f4f4f4] hover:text-[#171717]"
|
||||||
|
title="语音输入"
|
||||||
|
>
|
||||||
|
<Mic className="h-4 w-4" strokeWidth={1.9} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full text-[#6b6b6b] hover:bg-[#f4f4f4] hover:text-[#171717]"
|
||||||
|
title="帮助中心"
|
||||||
|
>
|
||||||
|
<BotMessageSquare className="h-4 w-4" strokeWidth={1.9} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#171717] text-white transition-colors hover:bg-[#303030]"
|
||||||
|
title="发送"
|
||||||
|
>
|
||||||
|
<SendHorizontal className="h-4 w-4" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageRow({ message }: { message: { role: string; title: string; content: string } }) {
|
||||||
|
const isUser = message.role === 'user';
|
||||||
|
|
||||||
|
if (isUser) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="max-w-[76%] rounded-3xl bg-[#f4f4f4] px-4 py-2.5 text-[14px] leading-6 text-[#171717]">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#171717] text-white">
|
||||||
|
<BotMessageSquare className="h-4 w-4" strokeWidth={1.9} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 pt-0.5">
|
||||||
|
<p className="mb-1 text-[13px] font-semibold text-[#171717]">{message.title}</p>
|
||||||
|
<p className="text-[14px] leading-7 text-[#202123]">{message.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert } from 'lucide-react';
|
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark } from 'lucide-react';
|
||||||
import { useHasPermission } from '@/components/auth/Guard';
|
import { useHasPermission } from '@/components/auth/Guard';
|
||||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||||
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
||||||
@@ -30,6 +30,7 @@ const NAV_GROUPS = [
|
|||||||
{
|
{
|
||||||
label: '工作区',
|
label: '工作区',
|
||||||
items: [
|
items: [
|
||||||
|
{ label: '问翻小宝', path: '/wenfan-xiaobao', icon: MessageCircleQuestionMark, permission: null as string | null },
|
||||||
{ label: '小宝预警', path: '/xiaobao-warning', icon: TriangleAlert, permission: 'xiaobao.warning:view', badge: 'xiaobao-risk' as const },
|
{ label: '小宝预警', path: '/xiaobao-warning', icon: TriangleAlert, permission: 'xiaobao.warning:view', badge: 'xiaobao-risk' as const },
|
||||||
{ label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null, badge: 'workspace-pending' as const },
|
{ label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null, badge: 'workspace-pending' as const },
|
||||||
{ label: '产品', path: '/products', icon: Package, permission: 'product:view' },
|
{ label: '产品', path: '/products', icon: Package, permission: 'product:view' },
|
||||||
|
|||||||
32
apps/web/lib/wenfan-xiaobao-ui.test.ts
Normal file
32
apps/web/lib/wenfan-xiaobao-ui.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
test('sidebar links wenfan xiaobao as an independent entry before xiaobao warning', () => {
|
||||||
|
const sidebar = readFileSync(join(process.cwd(), 'components/layout/Sidebar.tsx'), 'utf8');
|
||||||
|
|
||||||
|
const askIndex = sidebar.indexOf("label: '问翻小宝'");
|
||||||
|
const warningIndex = sidebar.indexOf("label: '小宝预警'");
|
||||||
|
|
||||||
|
assert.notEqual(askIndex, -1);
|
||||||
|
assert.notEqual(warningIndex, -1);
|
||||||
|
assert.ok(askIndex < warningIndex);
|
||||||
|
assert.match(sidebar, /path: '\/wenfan-xiaobao'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wenfan xiaobao page provides records, chat, and voice input surfaces', () => {
|
||||||
|
const pagePath = join(process.cwd(), 'app/wenfan-xiaobao/page.tsx');
|
||||||
|
assert.equal(existsSync(pagePath), true);
|
||||||
|
|
||||||
|
const page = readFileSync(pagePath, 'utf8');
|
||||||
|
|
||||||
|
assert.match(page, /历史记录/);
|
||||||
|
assert.match(page, /对话/);
|
||||||
|
assert.match(page, /textarea/);
|
||||||
|
assert.match(page, /Mic/);
|
||||||
|
assert.match(page, /lg:grid-cols-\[300px_minmax\(0,1fr\)\]/);
|
||||||
|
assert.match(page, /rounded-\[28px\]/);
|
||||||
|
assert.doesNotMatch(page, /: 'border-\[var\(--line\)\] bg-\[var\(--bg\)\]/);
|
||||||
|
assert.match(page, /: 'bg-white hover:bg-\[var\(--bg-subtle\)\]'/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user