Version: 0.9.82.dev.260507

后端:
1. 登录注册补齐极验行为验证与跨域入口:gateway 新增 `/user/captcha/register`,登录/注册先做 GeeTest 初始化与二次校验,再进入 user/auth RPC;补充验证码失败/初始化失败/服务不可用响应码,并新增可配置 CORS middleware 适配分域部署。
2. 容器部署配置入口收口:`bootstrap.LoadConfig` 支持 `SMARTFLOW_CONFIG_FILE` 与环境变量覆盖,`config.example.yaml` / `config.docker.yaml` 补齐 geetest 与容器内服务地址,网关新增配置列表解析,便于 compose 场景直接挂载配置启动。
3. LLM outbox 与助手时间线稳定性修正:`cmd/llm` 显式绑定 llm 自身 topic/group,避免误入 agent consumer group;agent timeline 在 Redis 热缓存未落 MySQL 时改用 `seq` 兜底临时 id,避免前端历史回放撞 key。

前端:
4. 认证页接入极验并补齐提交前校验:新增 GeeTest 脚本加载与实例封装,登录/注册面板支持 challenge 初始化、切换面板重挂载、失败提示与提交前校验,认证 API/types 同步透传 geetest 三元组。
5. 前端部署基址与网关对接收口:Axios `baseURL`、Vue Router `history base` 与 Vite `base/dev proxy` 改为读取环境变量,新增 `frontend/.env.example`,支持子路径部署、容器内反向代理和本地联调共存。
6. 助手与工作台展示细节修正:AssistantPanel 历史重建优先使用真实 timeline id、缺失时退回 `seq` 保证消息主键唯一;首页主面板改为纵向可滚动并补底部留白,避免内容截断。

仓库:
7. 整站容器化交付链路补齐并重写说明文档:新增后端/前端 Dockerfile、`.dockerignore`、前端 Nginx 代理、`docker-compose.full.yml`、`.env.full.example` 与镜像打包/导入脚本,README 改写数据库/路由/部署章节,并新增 `docs/容器化部署说明.md` 说明离线镜像分发方案。
This commit is contained in:
Losita
2026-05-07 00:58:27 +08:00
parent 7b04b073ce
commit 25a608eaeb
35 changed files with 2412 additions and 327 deletions

2
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

3
frontend/.env.example Normal file
View File

@@ -0,0 +1,3 @@
VITE_APP_BASE=/
VITE_API_BASE_URL=/api/v1
VITE_DEV_API_ORIGIN=http://127.0.0.1:8080

26
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# syntax=docker/dockerfile:1.7
ARG NODE_IMAGE=node:22-bookworm
ARG NGINX_IMAGE=nginx:1.27-alpine
FROM ${NODE_IMAGE} AS builder
WORKDIR /app
ARG VITE_APP_BASE=/
ARG VITE_API_BASE_URL=/api/v1
ENV VITE_APP_BASE=${VITE_APP_BASE}
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM ${NGINX_IMAGE} AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80

29
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 1. 生产环境统一由前端容器反代后端 API前端继续使用相对路径 /api/v1。
# 2. 关闭代理缓冲,避免 Agent SSE 流式响应被 Nginx 缓存后前端长时间收不到数据。
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
add_header X-Accel-Buffering no;
}
# 1. Vue Router 走 history 模式时,静态资源未命中需要回落到 index.html。
# 2. 这里不负责接口代理,接口统一由上面的 /api location 处理。
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -2,6 +2,7 @@ import http from '@/api/http'
import { extractErrorMessage } from '@/utils/http'
import type {
ApiResponse,
GeeTestRegisterData,
LoginPayload,
PlainResponse,
RefreshTokenPayload,
@@ -10,6 +11,21 @@ import type {
TokenPair,
} from '@/types/api'
export async function fetchGeeTestRegisterData() {
try {
const response = await http.get<ApiResponse<GeeTestRegisterData>>('/user/captcha/register', {
params: {
t: Date.now(),
},
skipAuth: true,
skipRefresh: true,
})
return response.data.data
} catch (error) {
throw new Error(extractErrorMessage(error, '人机验证初始化失败,请刷新页面后重试'))
}
}
export async function login(payload: LoginPayload) {
try {
const response = await http.post<ApiResponse<TokenPair>>('/user/login', payload, {

View File

@@ -18,13 +18,24 @@ declare module 'axios' {
}
}
function resolveApiBaseURL() {
const rawBaseURL = import.meta.env.VITE_API_BASE_URL?.trim() || '/api/v1'
if (/^https?:\/\//i.test(rawBaseURL)) {
return rawBaseURL.replace(/\/+$/, '')
}
const normalizedPath = `/${rawBaseURL.replace(/^\/+/, '')}`.replace(/\/+$/, '')
return normalizedPath || '/api/v1'
}
const apiBaseURL = resolveApiBaseURL()
const http = axios.create({
baseURL: '/api/v1',
baseURL: apiBaseURL,
timeout: 12000,
})
const refreshHttp = axios.create({
baseURL: '/api/v1',
baseURL: apiBaseURL,
timeout: 12000,
})

View File

@@ -2249,6 +2249,24 @@ function toggleHistoryPanel() {
historyExpanded.value = !historyExpanded.value
}
function buildTimelineDisplayMessageId(event: TimelineEvent, role: 'user' | 'assistant') {
// 1. Redis 热缓存里的 timeline 事件在未落 MySQL 前event.id 可能暂时为 0。
// 2. 如果这里仍直接使用 t-0 作为消息主键,不同轮次的 user / assistant 会撞到同一个前端状态桶。
// 3. 一旦发生撞 key工具卡片、正文块、thinking 块都会被后来的事件复用,表现成“每轮下面都挂着同一整份 assistant 内容”。
// 4. 因此优先使用真实 event.id缺失时退回会话内单调递增的 seq保证历史重建阶段的主键稳定且唯一。
const numericID = Number(event.id || 0)
if (Number.isFinite(numericID) && numericID > 0) {
return `t-${numericID}`
}
const numericSeq = Number(event.seq || 0)
if (Number.isFinite(numericSeq) && numericSeq > 0) {
return `t-${role}-${numericSeq}`
}
return createMessageId(role)
}
function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[]) {
const result: AssistantMessage[] = []
let currentAssistantMessage: AssistantMessage | null = null
@@ -2278,7 +2296,7 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
if (isUser) {
currentAssistantMessage = null
result.push({
id: `t-${event.id}`,
id: buildTimelineDisplayMessageId(event, 'user'),
role: 'user',
content: event.content || '',
createdAt: event.created_at,
@@ -2289,7 +2307,7 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
// 助手事件
if (!currentAssistantMessage) {
currentAssistantMessage = {
id: `t-${event.id}`,
id: buildTimelineDisplayMessageId(event, 'assistant'),
role: 'assistant',
content: '',
createdAt: event.created_at,

View File

@@ -10,7 +10,7 @@ import AssistantReasoningDebug from '@/views/debug/AssistantReasoningDebug.vue'
import HomeView from '@/views/HomeView.vue'
const router = createRouter({
history: createWebHistory(),
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',

View File

@@ -14,12 +14,25 @@ export interface TokenPair {
refresh_token: string
}
export interface LoginPayload {
export interface GeeTestValidateResult {
geetest_challenge: string
geetest_validate: string
geetest_seccode: string
}
export interface GeeTestRegisterData {
success: number
gt: string
challenge: string
new_captcha: boolean
}
export interface LoginPayload extends GeeTestValidateResult {
username: string
password: string
}
export interface RegisterPayload {
export interface RegisterPayload extends GeeTestValidateResult {
username: string
phone_number: string
password: string

View File

@@ -0,0 +1,97 @@
import type { GeeTestRegisterData, GeeTestValidateResult } from '@/types/api'
const GEETEST_SCRIPT_URL = 'https://static.geetest.com/static/tools/gt.js'
export interface GeeTestCaptchaInstance {
appendTo(element: string | HTMLElement): void
onReady(callback: () => void): void
onSuccess(callback: () => void): void
onError(callback: (error?: unknown) => void): void
getValidate(): GeeTestValidateResult | undefined
reset(): void
destroy?: () => void
}
interface GeeTestInitOptions extends GeeTestRegisterData {
product?: 'float' | 'popup' | 'custom'
width?: string
lang?: string
https?: boolean
}
type InitGeeTest = (
options: GeeTestInitOptions,
callback: (captcha: GeeTestCaptchaInstance) => void,
) => void
declare global {
interface Window {
initGeetest?: InitGeeTest
}
}
let scriptPromise: Promise<void> | null = null
// loadGeeTestScript 只负责把极验浏览器脚本加载进页面。
// 职责边界:
// 1. 负责去重加载,避免登录/注册切换时重复插入 script 标签;
// 2. 不负责初始化验证码实例,实例创建由 createGeeTestCaptcha 处理;
// 3. 若脚本拉取失败,直接抛错给上层 UI由页面决定如何提示用户。
export async function loadGeeTestScript() {
if (typeof window === 'undefined') {
throw new Error('当前环境不支持加载极验脚本')
}
if (window.initGeetest) {
return
}
if (!scriptPromise) {
scriptPromise = new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.src = GEETEST_SCRIPT_URL
script.async = true
script.defer = true
script.dataset.geetest = 'true'
script.onload = () => resolve()
script.onerror = () => reject(new Error('极验脚本加载失败'))
document.head.appendChild(script)
}).catch((error) => {
scriptPromise = null
throw error
})
}
await scriptPromise
if (!window.initGeetest) {
throw new Error('极验脚本加载完成,但初始化方法不可用')
}
}
// createGeeTestCaptcha 负责把后端返回的 challenge 转成一个可挂载到 DOM 的验证码实例。
// 职责边界:
// 1. 只负责 `initGeetest` 这一层包装,不关心表单提交逻辑;
// 2. 默认使用 `float + 100%`,保证验证码块可以稳定嵌入登录/注册按钮上方;
// 3. 返回值只暴露极验实例本身,验证结果仍由页面在提交前主动读取。
export async function createGeeTestCaptcha(registerData: GeeTestRegisterData) {
await loadGeeTestScript()
return new Promise<GeeTestCaptchaInstance>((resolve, reject) => {
const initGeetest = window.initGeetest
if (!initGeetest) {
reject(new Error('极验初始化方法不可用'))
return
}
initGeetest(
{
...registerData,
product: 'float',
width: '100%',
https: true,
},
(captcha) => {
resolve(captcha)
},
)
})
}

View File

@@ -1,12 +1,22 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { fetchGeeTestRegisterData } from '@/api/auth'
import { useAuthStore } from '@/stores/auth'
import type { GeeTestValidateResult } from '@/types/api'
import { createGeeTestCaptcha, type GeeTestCaptchaInstance } from '@/utils/geetest'
type PanelName = 'login' | 'register'
interface CaptchaPanelState {
instance: GeeTestCaptchaInstance | null
loading: boolean
ready: boolean
errorMessage: string
}
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
@@ -26,8 +36,180 @@ const registerForm = reactive({
password: '',
confirmPassword: '',
})
const loginCaptchaContainer = ref<HTMLDivElement | null>(null)
const registerCaptchaContainer = ref<HTMLDivElement | null>(null)
const captchaStates = reactive<Record<PanelName, CaptchaPanelState>>({
login: createCaptchaState(),
register: createCaptchaState(),
})
const redirectPath = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
let captchaMountToken = 0
function createCaptchaState(): CaptchaPanelState {
return {
instance: null,
loading: false,
ready: false,
errorMessage: '',
}
}
function getCaptchaContainer(panel: PanelName) {
const container = panel === 'login' ? loginCaptchaContainer.value : registerCaptchaContainer.value
if (container) {
return container
}
if (typeof document === 'undefined') {
return null
}
return document.querySelector<HTMLDivElement>(`[data-captcha-panel="${panel}"]`)
}
function teardownCaptcha(panel: PanelName) {
const state = captchaStates[panel]
state.instance?.destroy?.()
state.instance = null
state.loading = false
state.ready = false
state.errorMessage = ''
}
function getCaptchaHint(panel: PanelName) {
const state = captchaStates[panel]
if (state.errorMessage) {
return state.errorMessage
}
if (state.loading) {
return '人机验证加载中,请稍候...'
}
return '请先完成上方的人机验证,再继续提交。'
}
async function waitForCaptchaContainer(panel: PanelName, mountToken: number) {
// 1. 登录/注册面板切换使用了 `Transition mode="out-in"`。
// 2. 这意味着新面板的 DOM 不会在当前 tick 立即出现,只等一个 nextTick 还不够。
// 3. 这里用短轮询等待容器真正挂到页面上,超时后再显式报错,避免注册面板静默空白。
for (let attempt = 0; attempt < 8; attempt += 1) {
if (mountToken !== captchaMountToken) {
return null
}
const container = getCaptchaContainer(panel)
if (container) {
return container
}
await new Promise((resolve) => window.setTimeout(resolve, 80))
}
return null
}
async function initCaptcha(panel: PanelName, mountToken: number) {
const container = await waitForCaptchaContainer(panel, mountToken)
if (!container) {
if (mountToken === captchaMountToken) {
captchaStates[panel].errorMessage = '人机验证容器加载超时,请切换面板后重试'
}
return
}
const state = captchaStates[panel]
if (state.instance || state.loading) {
return
}
state.loading = true
state.ready = false
state.errorMessage = ''
try {
// 1. 先向后端申请一次最新 challenge保证当前面板拿到的是可立即提交的新验证码。
// 2. 再初始化极验实例并挂到按钮上方的容器里,满足页面布局要求。
// 3. 若用户切换了登录/注册面板,则放弃当前异步结果,避免旧实例串到新容器里。
const registerData = await fetchGeeTestRegisterData()
const captcha = await createGeeTestCaptcha(registerData)
if (mountToken !== captchaMountToken || activePanel.value !== panel) {
captcha.destroy?.()
return
}
state.instance = captcha
captcha.onReady(() => {
state.loading = false
state.ready = true
state.errorMessage = ''
})
captcha.onSuccess(() => {
state.errorMessage = ''
})
captcha.onError(() => {
state.loading = false
state.ready = false
state.errorMessage = '人机验证加载失败,请刷新页面后重试'
})
captcha.appendTo(container)
} catch (error) {
state.instance = null
state.loading = false
state.ready = false
state.errorMessage = error instanceof Error ? error.message : '人机验证初始化失败,请稍后重试'
ElMessage.error(state.errorMessage)
}
}
function getCaptchaResult(panel: PanelName): GeeTestValidateResult | null {
const state = captchaStates[panel]
if (state.errorMessage) {
ElMessage.warning(state.errorMessage)
return null
}
if (!state.ready || !state.instance) {
ElMessage.warning('人机验证正在初始化,请稍后再试')
return null
}
const validateResult = state.instance.getValidate()
if (!validateResult) {
ElMessage.warning('请先完成按钮上方的人机验证')
return null
}
return validateResult
}
function resetCaptcha(panel: PanelName) {
captchaStates[panel].errorMessage = ''
captchaStates[panel].instance?.reset()
}
async function handlePanelAfterEnter() {
await initCaptcha(activePanel.value, captchaMountToken)
}
watch(
activePanel,
async (panel, previousPanel) => {
captchaMountToken += 1
const mountToken = captchaMountToken
if (previousPanel) {
teardownCaptcha(previousPanel)
return
}
await nextTick()
if (mountToken !== captchaMountToken) {
return
}
await initCaptcha(panel, mountToken)
},
{ immediate: true },
)
onBeforeUnmount(() => {
captchaMountToken += 1
teardownCaptcha('login')
teardownCaptcha('register')
})
async function submitLogin() {
if (!loginForm.username.trim() || !loginForm.password.trim()) {
@@ -35,15 +217,22 @@ async function submitLogin() {
return
}
const captchaResult = getCaptchaResult('login')
if (!captchaResult) {
return
}
loginLoading.value = true
try {
await authStore.login({
username: loginForm.username.trim(),
password: loginForm.password,
...captchaResult,
})
ElMessage.success('登录成功,欢迎回来')
await router.push(redirectPath)
} catch (error) {
resetCaptcha('login')
ElMessage.error(error instanceof Error ? error.message : '登录失败')
} finally {
loginLoading.value = false
@@ -71,12 +260,18 @@ async function submitRegister() {
return
}
const captchaResult = getCaptchaResult('register')
if (!captchaResult) {
return
}
registerLoading.value = true
try {
await authStore.register({
username: registerForm.username.trim(),
phone_number: registerForm.phone_number.trim(),
password: registerForm.password,
...captchaResult,
})
loginForm.username = registerForm.username.trim()
loginForm.password = ''
@@ -85,6 +280,7 @@ async function submitRegister() {
activePanel.value = 'login'
ElMessage.success('注册成功,请使用新账号登录')
} catch (error) {
resetCaptcha('register')
ElMessage.error(error instanceof Error ? error.message : '注册失败')
} finally {
registerLoading.value = false
@@ -144,7 +340,7 @@ async function submitRegister() {
</div>
<div class="auth-form-container">
<Transition name="auth-fade" mode="out-in">
<Transition name="auth-fade" mode="out-in" @after-enter="handlePanelAfterEnter">
<div v-if="activePanel === 'login'" key="login">
<el-form label-position="top" class="auth-form" @submit.prevent="submitLogin">
<el-form-item label="用户名">
@@ -166,6 +362,15 @@ async function submitRegister() {
/>
</el-form-item>
<el-form-item label="人机验证" class="auth-captcha-item">
<div class="auth-captcha">
<div ref="loginCaptchaContainer" class="auth-captcha__box" data-captcha-panel="login" />
<p :class="['auth-captcha__hint', { 'is-error': Boolean(captchaStates.login.errorMessage) }]">
{{ getCaptchaHint('login') }}
</p>
</div>
</el-form-item>
<el-button
type="primary"
size="large"
@@ -218,6 +423,15 @@ async function submitRegister() {
/>
</el-form-item>
<el-form-item label="人机验证" class="auth-captcha-item">
<div class="auth-captcha">
<div ref="registerCaptchaContainer" class="auth-captcha__box" data-captcha-panel="register" />
<p :class="['auth-captcha__hint', { 'is-error': Boolean(captchaStates.register.errorMessage) }]">
{{ getCaptchaHint('register') }}
</p>
</div>
</el-form-item>
<el-button
type="primary"
size="large"
@@ -438,6 +652,26 @@ async function submitRegister() {
box-shadow: 0 0 0 2px #3b82f6 inset !important;
}
.auth-captcha {
display: grid;
gap: 8px;
}
.auth-captcha__box {
min-height: 44px;
}
.auth-captcha__hint {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #64748b;
}
.auth-captcha__hint.is-error {
color: #dc2626;
}
.auth-submit {
width: 100%;
height: 52px;

View File

@@ -410,7 +410,7 @@ watch([() => tasks.value.length, () => todayEvents.value.length, taskLoading, sc
transform-origin: center center;
}
.dashboard-main { min-width: 0; min-height: 0; overflow: hidden; height: 100%; }
.dashboard-main { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; height: 100%; }
.dashboard-main__scaled {
--dashboard-main-scale: 1;
@@ -447,7 +447,7 @@ watch([() => tasks.value.length, () => todayEvents.value.length, taskLoading, sc
.dashboard-topbar__profile strong { font-size: 13px; }
.dashboard-topbar__profile span { width: 38px; height: 38px; border-radius: 999px; background: #eef3fb; color: #314156; display: inline-flex; align-items: center; justify-content: center; font-weight: 800; }
.dashboard-content { width: 100%; display: grid; gap: 14px; align-content: start; }
.dashboard-content { width: 100%; display: grid; gap: 14px; align-content: start; padding-bottom: 60px; }
.dashboard-actions { display: flex; justify-content: flex-end; }
.dashboard-actions__primary { height: 42px; padding: 0 20px; border: none; border-radius: 15px; background: #3b82f6; color: #fff; font-weight: 700; cursor: pointer; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); }

View File

@@ -1,33 +1,44 @@
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
// 1. 开发环境把 /api 代理到本地 Go 服务,避免前端先处理跨域。
// 2. 这里只负责联调体验,不负责生产环境网关配置。
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
function normalizeBasePath(value: string | undefined) {
const trimmed = value?.trim()
if (!trimmed || trimmed === '/') {
return '/'
}
return `/${trimmed.replace(/^\/+|\/+$/g, '')}/`
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const appBase = normalizeBasePath(env.VITE_APP_BASE)
const devApiOrigin = env.VITE_DEV_API_ORIGIN?.trim() || 'http://127.0.0.1:8080'
return {
base: appBase,
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
},
server: {
port: 5173,
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
// SSE 流必须禁用缓冲,否则 Vite proxy 会攒满 buffer 再转发,
// 导致前端长时间收不到数据被判定为连接中断。
configure: (proxy) => {
proxy.on('proxyRes', (proxyRes) => {
proxyRes.headers['x-accel-buffering'] = 'no'
proxyRes.headers['cache-control'] = 'no-cache'
})
server: {
port: 5173,
host: '0.0.0.0',
proxy: {
'/api': {
target: devApiOrigin,
changeOrigin: true,
configure: (proxy) => {
proxy.on('proxyRes', (proxyRes) => {
proxyRes.headers['x-accel-buffering'] = 'no'
proxyRes.headers['cache-control'] = 'no-cache'
})
},
},
},
},
},
}
})