Hide H5 auth and document phase one scope

This commit is contained in:
2026-07-05 19:55:22 +08:00
parent fa2d82333c
commit 708e0a424e
4 changed files with 194 additions and 236 deletions

View File

@@ -0,0 +1,13 @@
# 项目文档索引
这个目录用于记录项目开发计划、产品决策、阶段范围和中间想法,方便后续开发、交接和复盘。
## 当前文档
- `h5-user-app-plan.md`H5 用户端一期范围、已隐藏功能、后续智能体与登录规划。
## 记录原则
- 先记录“为什么这样做”,再记录“做了什么”。
- 产品范围、技术边界、风险取舍要写清楚,避免后续只看代码猜意图。
- 已暂缓的功能不要删除背景,标明暂缓原因和恢复条件。

View File

@@ -0,0 +1,110 @@
# H5 用户端开发计划与决策记录
更新时间2026-07-05
## 当前目标
H5 用户端先作为“千问千答”用户入口,优先验证用户提问和系统回答这条主链路。第一期降低使用门槛,用户不登录也能进入页面并发起提问。
当前 H5 不是后台管理页,也不是知识库生产工具;它是未来小程序/用户端的交互雏形。
## 一期范围
一期只保留:
- 免登录进入 H5。
- 展示问答聊天界面。
- 用户输入问题。
- 前端调用后端已审核可调用库。
- 展示答案、来源和边界提示。
- 保留“新问答”按钮,方便用户重新开始。
- 保留复制和答案反馈的前端交互骨架。
一期明确不展示:
- 登录入口。
- 微信授权入口。
- 会话记录页。
- “我的”个人中心。
- 用户资料、手机号、微信昵称等身份信息。
## 当前隐藏决策
登录功能、微信授权、会话记录和个人中心此前已经做了部分前端预留,但一期先隐藏。
原因:
- 一期先验证问答能力,不让登录流程阻断用户体验。
- 后端还没有正式用户体系、会话历史接口和微信 code 换登录态接口。
- 如果展示半成品登录/记录/我的,会让测试用户误以为这些能力已经可用。
- 用户端要先把“能问、能答、答案可靠”打磨清楚。
恢复条件:
- 后端有正式用户登录接口。
- 后端支持微信授权登录或小程序登录。
- 后端支持用户会话保存和历史查询。
- 后端支持反馈入库。
## 当前接口绑定
H5 当前只适合直接绑定:
```http
GET /api/standard-qa/callable
```
这个接口只返回:
- 已审核标准问答。
- 已标记可调用。
- 低/中风险。
- 已脱敏或无需脱敏。
H5 不应该直接使用后台审核、编辑、任务、飞书、日志类接口。
## 后续后端方向
中长期不建议 H5 直接拉取全部可调用库并在前端匹配。更合理的结构是后端新增智能体接口:
```http
POST /api/h5/chat
```
后端智能体负责:
- 接收用户问题。
- 做风险初筛。
- 从标准问答可调用库中检索相关知识。
- 组装提示词。
- 调用大模型 API。
- 返回自然语言答案、来源、边界和命中状态。
- 保存会话和反馈。
H5 只负责:
- 输入问题。
- 展示答案。
- 展示来源和风险边界。
- 收集反馈。
- 管理用户交互状态。
## 后续建议开发顺序
1. 后端新增 `POST /api/h5/chat`
2. 后端新增智能体 prompt 和知识库检索模块。
3. H5 从直接调用 `/standard-qa/callable` 改成调用 `/h5/chat`
4. 后端新增用户会话和消息表。
5. H5 恢复会话历史页。
6. 后端新增反馈入库接口。
7. H5 恢复点赞、点踩、没解决等反馈入库。
8. 后端新增微信登录或小程序登录。
9. H5 恢复登录入口和“我的”页。
## 风险提醒
- 不要让 H5 使用未审核、不可调用、高风险或未脱敏内容。
- 不要让前端长期承担检索和风控职责。
- 大模型回答必须有明确边界,不能伪装成院长或老师原话。
- 医疗、法律、心理诊断、极端风险问题必须有兜底策略。
- 登录和历史记录上线前,要先确认隐私说明和数据保存规则。

View File

@@ -1,18 +1,12 @@
"use client"; "use client";
import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { FormEvent, useEffect, useRef, useState } from "react";
import { import {
BookOpenCheck, BookOpenCheck,
Clock3,
Home,
MessageCircle, MessageCircle,
Plus, Plus,
RotateCcw,
SendHorizonal, SendHorizonal,
Settings,
ShieldCheck, ShieldCheck,
Trash2,
UserRound
} from "lucide-react"; } from "lucide-react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { CallableQAItem } from "@/lib/types"; import type { CallableQAItem } from "@/lib/types";
@@ -26,10 +20,10 @@ import {
type ChatMessage type ChatMessage
} from "@/lib/h5MockQa"; } from "@/lib/h5MockQa";
import ChatBubble from "./ChatBubble"; import ChatBubble from "./ChatBubble";
import MockAuthPanel, { type MockUser } from "./MockAuthPanel";
type MobileTab = "qa" | "history" | "me";
const STORAGE_KEY = "hy-qa-h5-sessions"; const STORAGE_KEY = "hy-qa-h5-sessions";
const SHOW_AUTH_ENTRY = false;
const SHOW_HISTORY_AND_PROFILE = false;
function nowText() { function nowText() {
return new Intl.DateTimeFormat("zh-CN", { return new Intl.DateTimeFormat("zh-CN", {
@@ -66,19 +60,7 @@ function buildUnavailableMessage(reason: "loading" | "empty" | "error"): ChatMes
}; };
} }
function formatSessionTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}
export default function H5QAExperience() { export default function H5QAExperience() {
const [activeTab, setActiveTab] = useState<MobileTab>("qa");
const [authOpen, setAuthOpen] = useState(false);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages); const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [sessions, setSessions] = useState<ChatSession[]>([]); const [sessions, setSessions] = useState<ChatSession[]>([]);
@@ -86,7 +68,6 @@ export default function H5QAExperience() {
const [callableQa, setCallableQa] = useState<CallableQAItem[]>([]); const [callableQa, setCallableQa] = useState<CallableQAItem[]>([]);
const [loadingQa, setLoadingQa] = useState(true); const [loadingQa, setLoadingQa] = useState(true);
const [qaLoadError, setQaLoadError] = useState<string | null>(null); const [qaLoadError, setQaLoadError] = useState<string | null>(null);
const [user, setUser] = useState<MockUser | null>(null);
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null); const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(null); const bottomRef = useRef<HTMLDivElement | null>(null);
@@ -128,41 +109,9 @@ export default function H5QAExperience() {
.finally(() => setLoadingQa(false)); .finally(() => setLoadingQa(false));
}, []); }, []);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state");
if (code && state === "hyqa_h5") {
setUser({
mode: "wechat",
name: "微信已授权",
phone: "等待后端换取登录态"
});
window.history.replaceState(null, "", window.location.pathname);
}
}, []);
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages, activeTab]); }, [messages]);
function mockLogin(mode: MockUser["mode"]) {
setUser({
mode,
name: mode === "login" ? "大本营学员" : "新学员",
phone: "138****2026"
});
setAuthOpen(false);
}
function mockWechatLogin() {
setUser({
mode: "wechat",
name: "微信用户",
phone: "微信授权演示"
});
setAuthOpen(false);
}
function persistMessages(nextMessages: ChatMessage[]) { function persistMessages(nextMessages: ChatMessage[]) {
setMessages(nextMessages); setMessages(nextMessages);
@@ -179,21 +128,6 @@ export default function H5QAExperience() {
setCurrentSessionId(nextSession.id); setCurrentSessionId(nextSession.id);
setMessages(nextSession.messages); setMessages(nextSession.messages);
setSessions((current) => [nextSession, ...current].slice(0, 20)); setSessions((current) => [nextSession, ...current].slice(0, 20));
setActiveTab("qa");
}
function openSession(session: ChatSession) {
setCurrentSessionId(session.id);
setMessages(session.messages);
setActiveTab("qa");
}
function clearHistory() {
const nextSession = createEmptySession();
setCurrentSessionId(nextSession.id);
setMessages(nextSession.messages);
setSessions([nextSession]);
setActiveTab("qa");
} }
async function copyMessage(message: ChatMessage) { async function copyMessage(message: ChatMessage) {
@@ -239,7 +173,6 @@ export default function H5QAExperience() {
]; ];
persistMessages(nextMessages); persistMessages(nextMessages);
setInput(""); setInput("");
setActiveTab("qa");
} }
function submit(event: FormEvent<HTMLFormElement>) { function submit(event: FormEvent<HTMLFormElement>) {
@@ -259,8 +192,8 @@ export default function H5QAExperience() {
</div> </div>
</div> </div>
<div className="space-y-3 text-sm leading-6 text-slate-600"> <div className="space-y-3 text-sm leading-6 text-slate-600">
<p> mock </p> <p>使H5 </p>
<p></p> <p></p>
</div> </div>
</div> </div>
</aside> </aside>
@@ -280,12 +213,7 @@ export default function H5QAExperience() {
</div> </div>
</div> </div>
</div> </div>
<button {SHOW_AUTH_ENTRY ? null : null}
onClick={() => setAuthOpen(true)}
className="flex h-10 min-w-10 items-center justify-center rounded-md border border-line bg-white px-3 text-sm font-medium hover:bg-slate-50"
>
{user ? user.name.slice(0, 2) : <UserRound className="h-5 w-5" aria-hidden />}
</button>
</div> </div>
<div className="mt-3 flex items-center justify-between gap-2"> <div className="mt-3 flex items-center justify-between gap-2">
<button <button
@@ -300,11 +228,9 @@ export default function H5QAExperience() {
</header> </header>
<section className="relative flex min-h-0 flex-1 flex-col"> <section className="relative flex min-h-0 flex-1 flex-col">
{activeTab === "qa" ? (
<>
<div className="flex-1 overflow-y-auto px-4 py-4"> <div className="flex-1 overflow-y-auto px-4 py-4">
<div className="mb-4 rounded-md border border-teal-100 bg-teal-50 px-3 py-2 text-xs leading-5 text-teal-800"> <div className="mb-4 rounded-md border border-teal-100 bg-teal-50 px-3 py-2 text-xs leading-5 text-teal-800">
</div> </div>
{!loadingQa && (qaLoadError || callableQa.length === 0) ? ( {!loadingQa && (qaLoadError || callableQa.length === 0) ? (
<div className="mb-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800"> <div className="mb-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
@@ -348,132 +274,41 @@ export default function H5QAExperience() {
<SendHorizonal className="h-5 w-5" aria-hidden /> <SendHorizonal className="h-5 w-5" aria-hidden />
</button> </button>
</form> </form>
{SHOW_HISTORY_AND_PROFILE ? (
<div className="mt-3 rounded-md bg-panel px-3 py-2 text-xs text-slate-500">
</div> </div>
</> ) : null}
) : activeTab === "history" ? (
<div className="flex-1 overflow-y-auto px-4 py-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-base font-semibold"></h2>
<p className="mt-1 text-xs text-slate-500"></p>
</div> </div>
<button
onClick={clearHistory}
className="flex h-9 items-center gap-1 rounded-md border border-line px-2 text-xs text-slate-500 hover:bg-slate-50"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
<div className="space-y-3">
{sessions.map((session) => (
<button
key={session.id}
onClick={() => openSession(session)}
className={`w-full rounded-md border bg-white p-3 text-left shadow-sm ${
session.id === currentSessionId ? "border-teal-200 ring-2 ring-teal-50" : "border-line"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-ink">{session.title}</div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">{session.preview}</div>
</div>
<span className="shrink-0 text-xs text-slate-400">{formatSessionTime(session.updatedAt)}</span>
</div>
<div className="mt-3 text-xs text-slate-400">{session.questionCount} </div>
</button>
))}
</div>
</div>
) : activeTab === "me" ? (
<div className="flex-1 overflow-y-auto px-4 py-4">
<section className="rounded-md border border-line bg-white p-4 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-md bg-jade text-white">
<UserRound className="h-6 w-6" aria-hidden />
</div>
<div>
<div className="font-semibold text-ink">{user?.name ?? "未登录用户"}</div>
<div className="mt-1 text-sm text-slate-500">{user?.phone ?? "登录后同步问答记录和反馈"}</div>
</div>
</div>
<button
onClick={() => setAuthOpen(true)}
className="mt-4 h-10 w-full rounded-md bg-jade text-sm font-medium text-white hover:bg-teal-800"
>
{user ? "管理 mock 账号" : "登录 / 注册"}
</button>
</section>
<section className="mt-4 space-y-3 rounded-md border border-line bg-white p-4 text-sm text-slate-600 shadow-sm">
<div className="flex items-center justify-between">
<span></span>
<span className="font-medium text-ink">{sessions.length} </span>
</div>
<div className="flex items-center justify-between">
<span></span>
<span className="font-medium text-ink">{messages.filter((message) => message.feedback).length} </span>
</div>
<div className="flex items-center justify-between">
<span></span>
<span className="font-medium text-ink">{qaLoadError ? "接口异常" : callableQa.length > 0 ? "接口数据" : "等待入库"}</span>
</div>
</section>
<button
onClick={startNewSession}
className="mt-4 flex h-10 w-full items-center justify-center gap-2 rounded-md border border-line bg-white text-sm font-medium text-ink hover:bg-slate-50"
>
<RotateCcw className="h-4 w-4" aria-hidden />
</button>
</div>
) : (
null
)}
<MockAuthPanel
open={authOpen}
user={user}
onClose={() => setAuthOpen(false)}
onMockLogin={mockLogin}
onWechatMockLogin={mockWechatLogin}
onLogout={() => {
setUser(null);
setAuthOpen(false);
}}
/>
</section> </section>
<nav className="grid h-[62px] shrink-0 grid-cols-3 border-t border-line bg-white text-xs"> {SHOW_HISTORY_AND_PROFILE ? (
<nav className="hidden h-[62px] shrink-0 grid-cols-3 border-t border-line bg-white text-xs">
{[ {[
{ key: "qa" as const, label: "问答", icon: Home }, { label: "问答" },
{ key: "history" as const, label: "记录", icon: Clock3 }, { label: "记录" },
{ key: "me" as const, label: "我的", icon: Settings } { label: "我的" }
].map((item) => { ].map((item) => (
const Icon = item.icon;
const active = activeTab === item.key;
return (
<button <button
key={item.key} key={item.label}
onClick={() => setActiveTab(item.key)} className="flex flex-col items-center justify-center gap-1 text-slate-400"
className={`flex flex-col items-center justify-center gap-1 ${active ? "text-jade" : "text-slate-400"}`} type="button"
> >
<Icon className="h-5 w-5" aria-hidden />
<span>{item.label}</span> <span>{item.label}</span>
</button> </button>
); ))}
})}
</nav> </nav>
) : null}
</main> </main>
<aside className="hidden px-8 py-10 lg:block"> <aside className="hidden px-8 py-10 lg:block">
<div className="sticky top-10 max-w-sm rounded-md border border-line bg-white p-5 shadow-sm"> <div className="sticky top-10 max-w-sm rounded-md border border-line bg-white p-5 shadow-sm">
<h2 className="text-base font-semibold"></h2> <h2 className="text-base font-semibold"></h2>
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-600"> <ul className="mt-4 space-y-3 text-sm leading-6 text-slate-600">
<li> mock </li> <li> H5</li>
<li></li> <li>使 mock </li>
<li></li> <li></li>
<li>/</li> <li></li>
</ul> </ul>
</div> </div>
</aside> </aside>

View File

@@ -51,7 +51,7 @@ export const initialMessages: ChatMessage[] = [
role: "assistant", role: "assistant",
content: content:
"你好,我是千问千答助手。你可以把最近遇到的亲子、关系、情绪或行动问题发给我,我会优先基于已审核的答疑资产给你一个可落地的回应。", "你好,我是千问千答助手。你可以把最近遇到的亲子、关系、情绪或行动问题发给我,我会优先基于已审核的答疑资产给你一个可落地的回应。",
boundary: "当前为 H5 体验版,登录暂用 mock 占位;问答答案只读取后端可调用库。", boundary: "当前为 H5 体验版,一期免登录试用;问答答案只读取后端可调用库。",
createdAt: "09:30" createdAt: "09:30"
} }
]; ];