diff --git a/frontend/.cursorules/design.md b/.cursorules/design.md similarity index 100% rename from frontend/.cursorules/design.md rename to .cursorules/design.md diff --git a/frontend/.cursorules/vue.md b/.cursorules/vue.md similarity index 100% rename from frontend/.cursorules/vue.md rename to .cursorules/vue.md diff --git a/frontend/api/services.js b/frontend/api/services.js new file mode 100644 index 0000000000..bcc54ea270 --- /dev/null +++ b/frontend/api/services.js @@ -0,0 +1,113 @@ +/** + * 公共 API 服务 + * 封装可在 monorepo 各个应用中复用的 API 调用 + * + * 使用方式: + * import { createApiService } from '@gold/config/api/services' + * + * const apiService = createApiService({ + * http: axiosInstance, + * getAuthHeader: () => 'Bearer token', + * baseUrl: API_BASE.TIKHUB_APP + * }) + * + * await apiService.videoToCharacters({ fileLinkList: [...] }) + */ + +import { API_BASE } from '@gold/config/api' + +/** + * 创建 API 服务实例 + * @param {Object} options - 配置选项 + * @param {Object} options.http - HTTP 客户端实例(如 axios) + * @param {Function} options.getAuthHeader - 获取 Authorization header 的函数 + * @param {string} options.baseUrl - API 基础 URL(可选,默认使用 TIKHUB_APP) + * @returns {Object} API 服务对象 + */ +export function createApiService(options = {}) { + const { http, getAuthHeader, baseUrl } = options + + if (!http) { + throw new Error('createApiService: http 实例是必需的') + } + + // 确定 API 基础路径 + // 如果没有提供 baseUrl,尝试使用 TIKHUB_APP 或 TIKHUB + const apiBaseUrl = baseUrl || API_BASE.TIKHUB_APP || API_BASE.TIKHUB || '' + + /** + * 视频转字符(音频转文字) + * @param {Object} data - 请求数据 + * @param {string[]} data.fileLinkList - 音频文件链接列表 + * @returns {Promise} 响应数据 + */ + async function videoToCharacters(data) { + const url = `${apiBaseUrl}/videoToCharacters2` + + const headers = { + 'Content-Type': 'application/json', + } + + // 添加 Authorization header(如果提供了 getAuthHeader 函数) + if (getAuthHeader) { + const authHeader = getAuthHeader() + if (authHeader) { + headers.Authorization = authHeader + } + } + + // 获取 tenant-id(从环境变量或默认值) + const tenantId = + (typeof import.meta !== 'undefined' && import.meta.env?.VITE_TENANT_ID) || + (typeof process !== 'undefined' && process.env?.VITE_TENANT_ID) || + '1' + + if (tenantId) { + headers['tenant-id'] = tenantId + } + + return await http.post(url, data, { headers }) + } + + /** + * 调用工作流 + * @param {Object} data - 请求数据 + * @returns {Promise} 响应数据 + */ + async function callWorkflow(data) { + const url = `${apiBaseUrl}/callWorkflow` + + const headers = { + 'Content-Type': 'application/json', + } + + if (getAuthHeader) { + const authHeader = getAuthHeader() + if (authHeader) { + headers.Authorization = authHeader + } + } + + const tenantId = + (typeof import.meta !== 'undefined' && import.meta.env?.VITE_TENANT_ID) || + (typeof process !== 'undefined' && process.env?.VITE_TENANT_ID) || + '1' + + if (tenantId) { + headers['tenant-id'] = tenantId + } + + return await http.post(url, data, { headers }) + } + + return { + videoToCharacters, + callWorkflow, + } +} + +/** + * 默认导出(便于直接使用) + */ +export default createApiService + diff --git a/frontend/app/web-gold/package.json b/frontend/app/web-gold/package.json index 240ab363d1..525f08a530 100644 --- a/frontend/app/web-gold/package.json +++ b/frontend/app/web-gold/package.json @@ -20,7 +20,6 @@ "@microsoft/fetch-event-source": "^2.0.1", "@tailwindcss/vite": "^4.1.14", "ant-design-vue": "^4.2.6", - "axios": "^1.12.2", "dayjs": "^1.11.18", "markdown-it": "^14.1.0", "path-to-regexp": "^6.3.0", diff --git a/frontend/app/web-gold/src/App.vue b/frontend/app/web-gold/src/App.vue index 68730f4bf7..f9ec623447 100644 --- a/frontend/app/web-gold/src/App.vue +++ b/frontend/app/web-gold/src/App.vue @@ -5,6 +5,8 @@ import SidebarNav from './components/SidebarNav.vue' import TopNav from './components/TopNav.vue' import { theme } from 'ant-design-vue' import SvgSprite from '@/components/icons/SvgSprite.vue' +import { useUserStore } from '@/stores/user' +import { getToken } from '@gold/utils/token-manager' function readCssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || undefined @@ -24,7 +26,7 @@ const themeToken = ref({ } }) -onMounted(() => { +onMounted(async () => { // 运行时从 :root 读取,若存在则覆盖默认值 const next = { ...themeToken.value.token } next.colorPrimary = readCssVar('--color-primary') || next.colorPrimary @@ -35,6 +37,37 @@ onMounted(() => { next.colorTextSecondary = readCssVar('--color-text-secondary') || next.colorTextSecondary next.colorBorder = readCssVar('--color-border') || next.colorBorder themeToken.value = { algorithm: theme.darkAlgorithm, token: next } + + // 检查登录状态:如果有token但store中未标记为登录,则恢复登录状态 + const userStore = useUserStore() + + // 等待store从本地存储恢复完成(最多等待500ms) + let waitCount = 0 + while (!userStore.isHydrated && waitCount < 50) { + await new Promise(resolve => setTimeout(resolve, 10)) + waitCount++ + } + + const token = getToken() + if (token) { + // 如果有token但未登录,可能是刷新页面,需要恢复登录状态 + if (!userStore.isLoggedIn) { + userStore.isLoggedIn = true + // 尝试获取用户信息 + try { + await userStore.fetchUserInfo() + } catch (error) { + console.error('初始化用户信息失败:', error) + } + } else if (!userStore.nickname && !userStore.userId) { + // 如果已登录但没有用户信息,尝试获取 + try { + await userStore.fetchUserInfo() + } catch (error) { + console.error('获取用户信息失败:', error) + } + } + } }) diff --git a/frontend/app/web-gold/src/api/auth.js b/frontend/app/web-gold/src/api/auth.js index 5e9dfb87aa..1ba5fc71db 100644 --- a/frontend/app/web-gold/src/api/auth.js +++ b/frontend/app/web-gold/src/api/auth.js @@ -1,7 +1,8 @@ import api from '@/api/http' import { setToken, getRefreshToken } from '@/utils/auth' -import { API_BASE } from '@/api/config' +// 使用公共配置 +import { API_BASE } from '@gold/config/api' const SERVER_BASE = API_BASE.APP_MEMBER @@ -191,6 +192,17 @@ export async function resetPasswordBySms(mobile, newPassword, smsCode) { return data; } +/** + * 获取用户信息(C端) + * GET /member/user/get + * + * @returns {Promise} 用户信息对象 + */ +export async function getUserInfo() { + const { data } = await api.get(`${SERVER_BASE}/user/get`) + return data || {} +} + /** * “手机+验证码+密码注册”组合流程(基于短信登录即注册 + 设置密码) * 说明: @@ -235,4 +247,5 @@ export default { sendResetPasswordCode, resetPasswordBySms, registerWithMobileCodePassword, + getUserInfo, }; diff --git a/frontend/app/web-gold/src/api/chat.js b/frontend/app/web-gold/src/api/chat.js index c7b63010c1..4303022e0e 100644 --- a/frontend/app/web-gold/src/api/chat.js +++ b/frontend/app/web-gold/src/api/chat.js @@ -1,7 +1,8 @@ import request from '@/api/http' import { fetchEventSource } from '@microsoft/fetch-event-source' import { getAccessToken } from '@/utils/auth' -import { API_BASE } from '@/api/config' +// 使用公共配置 +import { API_BASE } from '@gold/config/api' const SERVER_BASE_AI = API_BASE.ADMIN_AI diff --git a/frontend/app/web-gold/src/api/common.js b/frontend/app/web-gold/src/api/common.js index 3d8f7acf78..0800382238 100644 --- a/frontend/app/web-gold/src/api/common.js +++ b/frontend/app/web-gold/src/api/common.js @@ -1,17 +1,32 @@ import http from '@/api/http' import { fetchEventSource } from '@microsoft/fetch-event-source' -import { getAuthHeader } from '@/utils/token-manager' -import { API_BASE } from '@/api/config' +import { getAuthHeader } from '@gold/utils/token-manager' +// 使用公共配置和 API 服务创建器 +import { API_BASE } from '@gold/config/api' +import { createApiService } from '@gold/api/services' +// 初始化公共 hook 的 API 服务 +import { setApiService } from '@gold/hooks/web/useVoiceText' // 使用本地代理前缀 /tikhub,开发环境通过 Vite 代理到 https://api.tikhub.io -const SERVER_BASE = API_BASE.TIKHUB +// 注意:API_BASE.TIKHUB 不存在,应该使用 TIKHUB_APP +const SERVER_BASE = API_BASE.TIKHUB_APP || API_BASE.TIKHUB || '' + +// 创建公共 API 服务实例 +const apiService = createApiService({ + http, + getAuthHeader, + baseUrl: SERVER_BASE, +}) + +// 设置全局 API 服务(供 useVoiceText hook 使用) +setApiService(apiService) export const CommonService = { videoToCharacters(data) { - return http.post(`${SERVER_BASE}/videoToCharacters2`, data) + return apiService.videoToCharacters(data) }, callWorkflow(data) { - return http.post(`${SERVER_BASE}/callWorkflow`, data) + return apiService.callWorkflow(data) }, // 流式调用 workflow diff --git a/frontend/app/web-gold/src/api/config.js b/frontend/app/web-gold/src/api/config.js index c60c2afffd..4c1768549c 100644 --- a/frontend/app/web-gold/src/api/config.js +++ b/frontend/app/web-gold/src/api/config.js @@ -1,34 +1,14 @@ /** * API 基础配置 * 统一管理所有 API 的基础 URL + * + * 注意:此文件已迁移到公共模块 @gold/config/api + * 为了保持向后兼容,这里重新导出公共配置 + * 新代码建议直接使用 @gold/config/api */ -const BASE_URL = import.meta.env.VITE_BASE_URL || '' +// 从公共模块导入 +export { API_BASE, getApiUrl } from '@gold/config/api' -/** - * API 基础路径配置 - */ -export const API_BASE = { - // 会员端 API - APP: `${BASE_URL}`, - // 具体模块路径 - ADMIN_AI: `${BASE_URL}/admin-api/ai`, - APP_MEMBER: `${BASE_URL}/member`, - - // 特殊路径 - TIKHUB_APP: `${BASE_URL}/api/tikHup`, -} -/** - * 获取完整的 API 路径 - * @param {string} module - 模块名称 (如 'ADMIN_AI', 'APP_MEMBER') - * @param {string} path - 接口路径 (如 '/chat/conversation/create-my') - * @returns {string} 完整的 API URL - */ -export function getApiUrl(module, path) { - const base = API_BASE[module] || API_BASE.ADMIN - return `${base}${path.startsWith('/') ? path : '/' + path}` -} - -export default API_BASE diff --git a/frontend/app/web-gold/src/api/example.js b/frontend/app/web-gold/src/api/example.js index 654eea3af8..eac71f7c64 100644 --- a/frontend/app/web-gold/src/api/example.js +++ b/frontend/app/web-gold/src/api/example.js @@ -41,7 +41,8 @@ async function example1() { // ========== 方式二:从具体文件导入(兼容旧代码) ========== import { ChatMessageApi } from '@/api/chat' import { CommonService } from '@/api/common' -import { API_BASE } from '@/api/config' +// 使用公共配置 +import { API_BASE } from '@gold/config/api' async function example2() { // 原有方式仍然可用 diff --git a/frontend/app/web-gold/src/api/http.js b/frontend/app/web-gold/src/api/http.js index 9671fcf37b..c2b3cf7781 100644 --- a/frontend/app/web-gold/src/api/http.js +++ b/frontend/app/web-gold/src/api/http.js @@ -1,5 +1,7 @@ import axios from 'axios' -import { getAuthHeader } from '@/utils/token-manager' +import { message } from 'ant-design-vue' +import { getAuthHeader, clearAllTokens } from '@gold/utils/token-manager' +import { useUserStore } from '@/stores/user' /** * 不需要 token 的接口白名单 @@ -57,6 +59,10 @@ http.interceptors.response.use( if (data && typeof data.code === 'number' && (data.code === 0 || data.code === 200)) { return data } else { + // code 不为 0 时,检查是否为401 + if (data && typeof data.code === 'number' && data.code === 401) { + handle401Error() + } // code 不为 0 时,抛出错误 const error = new Error(data?.message || data?.msg || '请求失败') error.code = data?.code @@ -65,11 +71,56 @@ http.interceptors.response.use( } }, (error) => { + // 处理 HTTP 状态码 401 + if (error.response && error.response.status === 401) { + handle401Error() + } // 统一错误处理:输出关键信息,便于排查 403 等问题 return Promise.reject(error) } ) +/** + * 处理 401 未授权错误 + * 清空 token 并退出登录 + * + * 注意:使用防抖机制避免多个请求同时401时重复处理 + */ +function handle401Error() { + // 避免重复处理(防止多个请求同时401导致多次调用) + if (handle401Error.processed) { + return + } + + handle401Error.processed = true + + // 1. 清空所有 token + try { + clearAllTokens() // 统一使用 token-manager 的清空函数 + } catch (e) { + console.error('清空 token 失败:', e) + } + + // 2. 退出登录状态(清空用户信息) + try { + const userStore = useUserStore() + // logout() 会清空用户信息和本地存储 + userStore.logout() + } catch (e) { + console.error('退出登录失败:', e) + } + + // 3. 提示用户(延迟显示,避免在清空过程中显示) + setTimeout(() => { + message.warning('登录已过期,请重新登录', 3) + }, 100) + + // 4. 延迟重置标志,避免短时间内重复处理 + setTimeout(() => { + handle401Error.processed = false + }, 2000) +} + export default http diff --git a/frontend/app/web-gold/src/api/tikhub/tikhub.js b/frontend/app/web-gold/src/api/tikhub/tikhub.js index e457ec0e2a..c286a1ce1a 100644 --- a/frontend/app/web-gold/src/api/tikhub/tikhub.js +++ b/frontend/app/web-gold/src/api/tikhub/tikhub.js @@ -1,7 +1,8 @@ import http from '@/api/http' import { InterfaceType, MethodType, InterfaceUrlMap, ParamType } from './types' import qs from 'qs' -import { API_BASE } from '@/api/config' +// 使用公共配置 +import { API_BASE } from '@gold/config/api' // 使用本地代理前缀 /tikhub,开发环境通过 Vite 代理到 https://api.tikhub.io const SERVER_TIKHUB = API_BASE.TIKHUB_APP diff --git a/frontend/app/web-gold/src/components/LoginModal.vue b/frontend/app/web-gold/src/components/LoginModal.vue index b87f679774..729c477d72 100644 --- a/frontend/app/web-gold/src/components/LoginModal.vue +++ b/frontend/app/web-gold/src/components/LoginModal.vue @@ -226,6 +226,10 @@ async function handleLoginSuccess(info) { credits: userInfo.credits ?? 0, } }) + + // 登录成功后获取完整的用户信息 + await userStore.fetchUserInfo() + close() } diff --git a/frontend/app/web-gold/src/components/TokenInput.vue b/frontend/app/web-gold/src/components/TokenInput.vue index bed3ce32b0..1fb32b2b92 100644 --- a/frontend/app/web-gold/src/components/TokenInput.vue +++ b/frontend/app/web-gold/src/components/TokenInput.vue @@ -1,7 +1,7 @@ + + + + diff --git a/frontend/app/web-gold/src/router/index.js b/frontend/app/web-gold/src/router/index.js index 8d63c1e007..7b8500366d 100644 --- a/frontend/app/web-gold/src/router/index.js +++ b/frontend/app/web-gold/src/router/index.js @@ -1,4 +1,6 @@ import { createRouter, createWebHistory } from 'vue-router' +import { useUserStore } from '@/stores/user' +import { getToken } from '@gold/utils/token-manager' const routes = [ { @@ -48,4 +50,40 @@ const router = createRouter({ routes, }) +// 用户信息初始化标志(确保只初始化一次) +let userInfoInitialized = false + +/** + * 路由导航守卫:初始化用户信息 + * 在首次路由跳转时,如果已登录(有 token),则获取用户信息 + */ +router.beforeEach(async (to, from, next) => { + // 只在首次路由跳转时初始化用户信息 + if (!userInfoInitialized) { + userInfoInitialized = true + + const token = getToken() + if (token) { + try { + const userStore = useUserStore() + // 如果 store 中已标记为登录,则获取用户信息 + if (userStore.isLoggedIn) { + userStore.fetchUserInfo() + } else { + // 如果有 token 但 store 中未标记为登录,可能是刷新页面 + // 先标记为已登录,然后获取用户信息 + userStore.isLoggedIn = true + userStore.fetchUserInfo() + } + } catch (error) { + console.error('初始化用户信息失败:', error) + // 不阻止路由跳转,继续执行 + } + } + } + + // 继续路由跳转 + next() +}) + export default router diff --git a/frontend/app/web-gold/src/stores/user.js b/frontend/app/web-gold/src/stores/user.js index d6d43988e8..b19b0fe6db 100644 --- a/frontend/app/web-gold/src/stores/user.js +++ b/frontend/app/web-gold/src/stores/user.js @@ -1,6 +1,7 @@ import { ref, computed, watch } from 'vue' import { defineStore } from 'pinia' import { getJSON, setJSON, remove } from '@/utils/storage' +import { clearAllTokens } from '@gold/utils/token-manager' // 本地持久化的 key const STORAGE_KEY = 'user_store_v1' @@ -105,7 +106,48 @@ export const useUserStore = defineStore('user', () => { await persist() } + /** + * 获取用户信息(从后端) + * 登录成功后调用,更新用户信息 + * 使用公共 hook @gold/hooks/web/useUserInfo + */ + async function fetchUserInfo() { + try { + // 使用公共 hook 获取用户信息 + const { getUserInfo } = await import('@gold/hooks/web/useUserInfo') + const { getToken } = await import('@gold/utils/token-manager') + + const userInfo = await getUserInfo({ + getToken, + }) + + if (userInfo) { + // 更新用户信息 + userId.value = String(userInfo.id || userInfo.userId || userId.value) + nickname.value = userInfo.nickname || nickname.value + avatar.value = userInfo.avatar || avatar.value + // 如果有其他字段,也可以更新 + if (userInfo.point !== undefined) credits.value = Number(userInfo.point || 0) + if (userInfo.experience !== undefined) { + // experience 可以映射到其他字段,根据实际需求 + } + await persist() + } + } catch (error) { + console.error('获取用户信息失败:', error) + // 不抛出错误,避免影响登录流程 + } + } + async function logout() { + // 1. 清空所有 token + try { + clearAllTokens() + } catch (e) { + console.error('清空 token 失败:', e) + } + + // 2. 清空用户信息 isLoggedIn.value = false userId.value = '' nickname.value = '' @@ -117,13 +159,22 @@ export const useUserStore = defineStore('user', () => { balance.value = 0 vipLevel.value = 0 credits.value = 0 + + // 3. 删除本地存储的用户数据 await remove(STORAGE_KEY) } + // 初始化标志 + const isHydrated = ref(false) + // 初始化从本地恢复 - hydrateFromStorage() + hydrateFromStorage().then(() => { + isHydrated.value = true + }) return { + // 状态 + isHydrated, // state isLoggedIn, userId, @@ -143,6 +194,7 @@ export const useUserStore = defineStore('user', () => { loginWithPhone, loginWithWeChat, updateBalance, + fetchUserInfo, logout, } }) diff --git a/frontend/app/web-gold/src/utils/auth.js b/frontend/app/web-gold/src/utils/auth.js index f0f04b124d..d61c9c0166 100644 --- a/frontend/app/web-gold/src/utils/auth.js +++ b/frontend/app/web-gold/src/utils/auth.js @@ -1,42 +1,54 @@ -import { useCache } from '@gold/hooks/web/useCache' -import { getToken as getTokenFromManager } from './token-manager' +/** + * Token 管理兼容层 + * + * @deprecated 此文件已废弃,请直接使用 @gold/utils/token-manager + * + * 为了保持向后兼容,此文件作为兼容层,所有函数都转发到 token-manager.js + * 新代码请直接使用 @gold/utils/token-manager 中的函数 + */ -const { wsCache } = useCache() +import { + getToken as getTokenFromManager, + setToken as setTokenFromManager, + getRefreshToken as getRefreshTokenFromManager, + getAccessToken as getAccessTokenFromManager, + removeToken as removeTokenFromManager, + clearAllTokens, + getAuthHeader +} from '@gold/utils/token-manager' -const AccessTokenKey = 'ACCESS_TOKEN' -const RefreshTokenKey = 'REFRESH_TOKEN' +/** + * 获取访问令牌 + * @deprecated 使用 @gold/utils/token-manager 中的 getToken() 或 getAccessToken() + */ +export const getAccessToken = getAccessTokenFromManager -// 获取token - 使用统一的 token 管理器 -export const getAccessToken = () => { - return getTokenFromManager() -} +/** + * 获取刷新令牌 + * @deprecated 使用 @gold/utils/token-manager 中的 getRefreshToken() + */ +export const getRefreshToken = getRefreshTokenFromManager -// 刷新token -export const getRefreshToken = () => { - try { - // 优先从 wsCache 读取 - const refreshToken = wsCache.get(RefreshTokenKey) || wsCache.get('refresh_token') - if (refreshToken) { - return refreshToken - } - } catch (e) { - console.warn('获取 refresh token 失败:', e) - } - return null -} -// 设置token -export const setToken = (token) => { - wsCache.set(RefreshTokenKey, token.refreshToken) - wsCache.set(AccessTokenKey, token.accessToken) -} +/** + * 设置令牌 + * @deprecated 使用 @gold/utils/token-manager 中的 setToken() + */ +export const setToken = setTokenFromManager -// 删除token -export const removeToken = () => { - wsCache.delete(AccessTokenKey) - wsCache.delete(RefreshTokenKey) -} +/** + * 删除令牌 + * @deprecated 使用 @gold/utils/token-manager 中的 clearAllTokens() 或 removeToken() + */ +export const removeToken = removeTokenFromManager -/** 格式化token(jwt格式) */ +/** + * 格式化token(jwt格式) + * @param {string} token - token 字符串 + * @returns {string} Bearer token + */ export const formatToken = (token) => { return 'Bearer ' + token } + +// 导出 token-manager 的其他函数,方便迁移 +export { clearAllTokens, getAuthHeader } diff --git a/frontend/app/web-gold/src/utils/token-manager.js b/frontend/app/web-gold/src/utils/token-manager.js deleted file mode 100644 index c937041a6b..0000000000 --- a/frontend/app/web-gold/src/utils/token-manager.js +++ /dev/null @@ -1,100 +0,0 @@ -import { useCache } from '@gold/hooks/web/useCache' - -/** - * Token 统一管理模块 - * - * 优先级顺序: - * 1. 手动输入的 dev token (sessionStorage) - * 2. 正式登录的 token (wsCache) - * 3. 环境变量 VITE_DEV_TOKEN - */ - -// sessionStorage 中的手动 token key -const DEV_MANUAL_TOKEN_KEY = 'DEV_MANUAL_TOKEN' - -// 获取缓存实例 -let wsCache = null -function getCache() { - if (!wsCache) { - wsCache = useCache().wsCache - } - return wsCache -} - -/** - * 获取完整的 Authorization Header 值 - * @returns {string} Bearer token 或空字符串 - */ -export function getAuthHeader() { - const token = getToken() - return token ? `Bearer ${token}` : '' -} - -/** - * 获取 token - * @returns {string} token 字符串 - */ -export function getToken() { - // 1. 优先使用手动输入的 dev token - const manualToken = sessionStorage.getItem(DEV_MANUAL_TOKEN_KEY) - if (manualToken) { - return manualToken - } - - // 2. 使用正式登录的 token(从 wsCache 读取) - try { - const cache = getCache() - const accessToken = cache.get('ACCESS_TOKEN') || cache.get('access_token') - - if (accessToken) { - return accessToken - } - } catch (e) { - console.warn('获取 wsCache 失败:', e) - } - - // 3. 兜底:环境变量中的 token - const envToken = import.meta?.env?.VITE_DEV_TOKEN - if (envToken) { - return envToken - } - - return '' -} - -/** - * 设置手动输入的 dev token - * @param {string} token - */ -export function setDevToken(token) { - if (token) { - sessionStorage.setItem(DEV_MANUAL_TOKEN_KEY, token) - } else { - sessionStorage.removeItem(DEV_MANUAL_TOKEN_KEY) - } -} - -/** - * 获取手动输入的 dev token(用于显示) - * @returns {string} - */ -export function getDevToken() { - return sessionStorage.getItem(DEV_MANUAL_TOKEN_KEY) || '' -} - -/** - * 清除所有 token - */ -export function clearAllTokens() { - sessionStorage.removeItem(DEV_MANUAL_TOKEN_KEY) - try { - const cache = getCache() - cache.delete('ACCESS_TOKEN') - cache.delete('access_token') - cache.delete('REFRESH_TOKEN') - cache.delete('refresh_token') - } catch (e) { - console.warn('清除 wsCache 失败:', e) - } -} - diff --git a/frontend/app/web-gold/vite.config.js b/frontend/app/web-gold/vite.config.js index 9cecc76467..0efee63264 100644 --- a/frontend/app/web-gold/vite.config.js +++ b/frontend/app/web-gold/vite.config.js @@ -21,7 +21,7 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') const DEV_TOKEN = env.VITE_DEV_TOKEN || '' const TENANT_ID = env.VITE_TENANT_ID || '1' - const API_TARGET = env.VITE_PROXY_TARGET || 'http://8.155.172.147:9900' + const API_TARGET = env.VITE_PROXY_TARGET || 'http://localhost:9900' return { plugins: [ @@ -61,6 +61,7 @@ export default defineConfig(({ mode }) => { }, server: { proxy: { + // 代理 /webApi 开头的请求 '/webApi': { target: API_TARGET, changeOrigin: true, diff --git a/frontend/config/api.js b/frontend/config/api.js new file mode 100644 index 0000000000..180d57dc88 --- /dev/null +++ b/frontend/config/api.js @@ -0,0 +1,51 @@ +/** + * 公共 API 基础配置 + * 统一管理所有 API 的基础 URL + * 可在各个应用中通过 @gold/config/api 引用 + */ + +/** + * 获取基础 URL(从环境变量读取) + * @returns {string} + */ +function getBaseUrl() { + // 支持在浏览器环境和 Node 环境 + if (typeof import.meta !== 'undefined' && import.meta.env) { + return import.meta.env.VITE_BASE_URL || '' + } + // 如果是在 Node 环境或 SSR,可以从 process.env 读取 + if (typeof process !== 'undefined' && process.env) { + return process.env.VITE_BASE_URL || '' + } + return '' +} + +const BASE_URL = getBaseUrl() + +/** + * API 基础路径配置 + */ +export const API_BASE = { + // 会员端 API + APP: `${BASE_URL}`, + // 具体模块路径 + ADMIN_AI: `${BASE_URL}/admin-api/ai`, + APP_MEMBER: `${BASE_URL}/app-api/member`, + + // 特殊路径 + TIKHUB_APP: `${BASE_URL}/api/tikHup`, +} + +/** + * 获取完整的 API 路径 + * @param {string} module - 模块名称 (如 'ADMIN_AI', 'APP_MEMBER') + * @param {string} path - 接口路径 (如 '/chat/conversation/create-my') + * @returns {string} 完整的 API URL + */ +export function getApiUrl(module, path) { + const base = API_BASE[module] || API_BASE.APP + return `${base}${path.startsWith('/') ? path : '/' + path}` +} + +export default API_BASE + diff --git a/frontend/config/types.ts b/frontend/config/types.ts new file mode 100644 index 0000000000..250e070ae6 --- /dev/null +++ b/frontend/config/types.ts @@ -0,0 +1,52 @@ +/** + * 公共类型定义 + * 可在 monorepo 各个应用中复用 + */ + +/** + * 音频项接口 + */ +export interface AudioItem { + audio_url: string +} + +/** + * 转录结果接口 + */ +export interface TranscriptionResult { + key: string + audio_url?: string + value: string +} + +/** + * 视频转字符请求参数 + */ +export interface VideoToCharactersRequest { + fileLinkList: string[] +} + +/** + * 视频转字符响应接口 + */ +export interface VideoToCharactersResponse { + data: string // JSON 字符串,包含 results 数组 +} + +/** + * 转录响应结果 + */ +export interface TranscriptionResponse { + results: Array<{ + transcription_url: string + }> +} + +/** + * 转录数据接口 + */ +export interface TranscriptionData { + file_url?: string + transcripts?: Array<{ text: string }> +} + diff --git a/frontend/doc/readme.md b/frontend/doc/readme.md deleted file mode 100644 index 62310f0f0f..0000000000 --- a/frontend/doc/readme.md +++ /dev/null @@ -1,146 +0,0 @@ -## 金牌内容大师软件页面结构 - -### 整体布局 -- 顶部导航(深灰):左侧 Logo(紫底白“逗”字 + 文案),右侧通知铃铛(红点数)、用户头像、主题切换 -- 一级模块导航(横向 Tab/菜单): - - 内容风格分析(含 对标分析|文案创作) - - 热点趋势分析(含 热度分析|热点预测|文案创作) - - 数字人(含 声音训练|生成配音|生成数字人) - - 实时热点推送 - - 素材混剪 - - 剪映导入 - - 购买会员(优惠标签)|推广赚钱(HOT 标签)|帮助|下载 -- 主内容区(白色背景):按选中模块展示对应子模块页面 -- 底部信息(可选):版本号、API 状态、版权提示 - - -### 模块与子模块页面结构 - -#### 模块一:内容风格分析 -1) 子模块:对标分析 -- 顶部:标题“对标分析” + 简介 + 帮助入口 -- 操作面板(左侧卡片): - - 链接输入区:平台选择单选(抖音|小红书)+ 主页/视频链接输入框 - - 筛选项:点赞/收藏/评论范围、多选标签、时间范围、数量上限 - - 执行按钮:开始分析(主色)、清空、示例一键填充 -- 结果区(右侧/下方): - - 概览卡片:账号画像、样本量、时间窗口 - - 结构分析卡片:标题结构要素、镜头/段落结构、开场/转场/收束模式 - - 关键词统计:词云 + 频次表格 - - 情绪倾向:柱状/雷达图(正/负/中性 + 情绪强度) - - 文本质量检测:错别字、语法、可读性评分 - - 导出区:生成 Word 报告(含目录/图表/表格)、复制结论摘要 - - 扩展卡片:风格特征提示词(结构模式、表达方式、比喻/设问等) - -2) 子模块:文案创作 -- 顶部:标题“文案创作” + 风格来源选择(对标分析结果/手动配置) -- 左侧:创作参数卡片 - - 主题/选题输入、受众画像、平台体裁(短视频/图文)、字数与段落数 - - 风格特征(从对标分析继承或手动选择):语气、句式密度、金句频率、节奏 - - 约束:禁用词、合规提醒、敏感词检测 - - 生成按钮:生成文案(可多样本) -- 右侧:结果编辑器 - - 多样本标签页切换、评分与重写、段落级润色与扩写 - - 结构优化建议面板(共享逻辑):标题建议、开头抓取点、转场优化、CTA 变体 - - 导出:复制、保存为草稿、导出 Markdown/Word - -#### 模块二:热点趋势分析 -1) 子模块:热度分析 -- 顶部:标题“热度分析” + 数据源指示(官方 API|第三方) -- 左侧:关键词与订阅 - - 关键词输入(支持多个),平台选择,时间窗口,频率(实时/每日) - - 订阅开关:关注话题变化通知 - - 查询按钮 -- 右侧:趋势看板 - - 热度折线/面积图(支持对比多个关键词) - - 相关话题分布(词云/桑基/气泡图) - - 榜单列表(热度值、涨跌幅、来源、时间) - - 个性化推荐(可选):基于历史偏好推荐相近话题 - - 竞品分析(可选):同类内容的表现对比与优化方向 - - 导出:趋势报告 PDF/图片、订阅保存 - -2) 子模块:热点预测 -- 顶部:标题“热点预测” + 更新频率 + 最近同步时间 -- 左侧:数据设置 - - 平台榜单选择、历史维度、预测周期、预测方法(可选) - - 执行按钮:计算预测 -- 右侧:预测结果 - - 趋势外推曲线与置信区间 - - 上升/下降话题榜卡片 - - 风险提示与置信度标注 - - 原创建议(核心扩展):基于趋势给出选题与角度建议,一键跳转“文案创作” - -3) 子模块:文案创作(与模块一共享逻辑) -- 入口参数预填:来自热度分析/热点预测的关键词与角度 -- 结果编辑器同模块一,增加“热点引用标注”与“数据出处注记” - -#### 模块三:数字人 -1) 子模块:声音训练 -- 左侧:样本上传 - - 上传语音样本(数量/时长提示)、采样率与格式要求、合法性合规弹窗 - - 训练参数:噪声抑制、音域范围、情感维度 - - 提交训练按钮 -- 右侧:训练进度与质量 - - 进度条、阶段日志、预计完成时间 - - 质量检测结果:信噪比、清晰度、稳定性评分 - - 优化建议卡片 - -2) 子模块:生成配音 -- 左侧:文本输入与语气控制 - - 文案输入(或从“文案创作”选择)、说话速度、停连、情感标签 - - 说话人(训练好的声线列表)、发音词典(可选) - - 生成按钮 + 批量生成 -- 右侧:音频预览与管理 - - 播放、片段试听、对比 A/B、噪声/呼吸/口型对齐提示 - - 质量检测报告(可选):节奏、情感一致性、清晰度 - - 下载(mp3/wav)与导出到“剪映导入” - -3) 子模块:生成数字人 -- 左侧:配置 - - 数字人形象选择、背景与模板、脚本来源(文案 + 配音) - - 表情与动作控制(关键帧/预设)、唇形同步开关 - - 分辨率、时长、字幕选项 - - 生成按钮 -- 右侧:视频预览与任务队列 - - 渲染进度、日志、错误重试 - - 导出(mp4|mov)与推送至“剪映导入” - -#### 模块四:实时热点推送 -- 榜单看板:平台切换、实时/小时/日视图 -- 订阅管理:新增/编辑/删除、通知频率、通知方式 -- 趋势联动:一键进入“热点预测”分析 - -#### 模块五:素材混剪 -- 左侧:文案拆解 - - 从“文案创作”选择文案,自动分段(镜头/语义) - - 段落卡片:时长建议、情绪标签、镜头类型 -- 右侧:素材匹配与时间线 - - 素材库搜索(标签/颜色/场景/人物),AI 推荐位 - - 画布与时间线:轨道(视频/音频/字幕),吸附与对齐 - - 一键组合:按模版快速生成初版 - - 导出到“剪映导入” - -#### 模块六:剪映导入 -- 导入项选择:文案、字幕文件、配音音频、数字人视频、混剪工程 -- 格式兼容提示:编码、帧率、分辨率、自适配策略 -- 一键导入:生成剪映工程文件/素材包,打开路径/直接启动剪映(可选) - -### 设计要点(模块化增强) -- 色彩:紫色主色、深灰导航、白色内容区;状态色区分任务与错误 -- 图标:模块与子模块使用语义明确图标,功能与结果分区清晰 -- 交互:双栏布局优先(左操作右结果/预览),异步任务显著进度提示 -- 信息层次:概览 > 关键图表/卡片 > 详细表格/日志 -- 模块联动:子模块间保留“送往/引入”操作(如趋势 → 文案、文案 → 配音/数字人、生成项 → 剪映) -- 合规与提示:上传/生成环节均有合规提醒与风险提示,不展示原始受版权保护内容 - -### 技术实现提示词 -- 前端:Vue.js + Vite + Tailwind CSS,组件化(导航、表格、图表、上传、时间线) -- 状态管理:用户信息、通知数量、主题、任务队列、订阅配置 -- 图表:折线/面积/柱状/雷达/词云(ECharts 或 Chart.js) -- 文件:音视频上传与转码进度、任务轮询、失败重试 -- API 接口:模块化设计,参数入参与结构化响应,跨模块数据传递(如选题、风格特征、音视频产物) -- 导出:Word/PDF 报告、媒体文件、剪映工程/素材包 - -### 工作流快捷入口(横向步骤条) -- 对标分析 → 文案创作 → 生成配音/数字人 → 素材混剪 → 剪映导入 -- 每步显示完成状态与产物摘要,支持回溯与替换 \ No newline at end of file diff --git a/frontend/hooks/web/README.md b/frontend/hooks/web/README.md new file mode 100644 index 0000000000..2c5cf38187 --- /dev/null +++ b/frontend/hooks/web/README.md @@ -0,0 +1,176 @@ +# 公共 Hooks 模块 + +此目录包含可在 monorepo 各个应用中复用的 Vue Composition API Hooks。 + +## 📁 目录结构 + +``` +hooks/web/ +├── useCache.js # 缓存管理 Hook +├── useUserInfo.js # 用户信息获取 Hook +└── useVoiceText.ts # 语音文本处理 Hook(已抽离为公共模块) +``` + +## 🚀 使用方式 + +### useUserInfo Hook + +获取用户信息的公共 Hook,可在各个应用中复用。 + +#### 基础用法 + +```javascript +import { useUserInfo } from '@gold/hooks/web/useUserInfo' +import { getToken } from '@/utils/token-manager' + +// 在组件中使用 +const { fetchUserInfo, loading, error, userInfo } = useUserInfo({ + getToken, // 传入获取 token 的函数 +}) + +// 获取用户信息 +await fetchUserInfo() +``` + +#### 便捷函数 + +如果只需要获取一次用户信息,可以使用便捷函数: + +```javascript +import { getUserInfo } from '@gold/hooks/web/useUserInfo' +import { getToken } from '@/utils/token-manager' + +// 直接获取用户信息 +const userInfo = await getUserInfo({ + getToken, +}) +``` + +#### 自定义配置 + +```javascript +const { fetchUserInfo } = useUserInfo({ + baseUrl: '/custom-api/member', // 自定义 API 基础 URL + getToken: () => { + // 自定义获取 token 的逻辑 + return localStorage.getItem('token') + }, +}) +``` + +## 📝 API 说明 + +### useUserInfo(options) + +**参数:** +- `options.baseUrl` (string, 可选): API 基础 URL,默认使用 `API_BASE.APP_MEMBER` +- `options.getToken` (Function, 可选): 获取 token 的函数 + +**返回值:** +- `fetchUserInfo()` (Function): 获取用户信息的异步函数 +- `loading` (Ref): 加载状态 +- `error` (Ref): 错误信息 +- `userInfo` (Ref): 用户信息数据 + +### getUserInfo(options) + +便捷函数,直接返回用户信息对象。 + +**参数:** 同 `useUserInfo` + +**返回值:** `Promise` 用户信息对象 + +## 🔄 在 Store 中使用 + +```javascript +import { defineStore } from 'pinia' +import { getUserInfo } from '@gold/hooks/web/useUserInfo' +import { getToken } from '@/utils/token-manager' + +export const useUserStore = defineStore('user', () => { + const userInfo = ref(null) + + async function fetchUserInfo() { + try { + const data = await getUserInfo({ getToken }) + userInfo.value = data + } catch (error) { + console.error('获取用户信息失败:', error) + } + } + + return { userInfo, fetchUserInfo } +}) +``` + +### useVoiceText Hook + +语音文本转换 Hook,将音频文件转换为文本转录。 + +#### 初始化(在应用启动时) + +```javascript +// 在应用的 API 服务文件中(如 common.js) +import { createApiService } from '@gold/config/api/services' +import { setApiService } from '@gold/hooks/web/useVoiceText' +import http from '@/api/http' +import { getAuthHeader } from '@/utils/token-manager' +import { API_BASE } from '@gold/config/api' + +// 创建 API 服务实例 +const apiService = createApiService({ + http, + getAuthHeader, + baseUrl: API_BASE.TIKHUB_APP, +}) + +// 设置全局 API 服务(供 useVoiceText hook 使用) +setApiService(apiService) +``` + +#### 使用方式 + +```javascript +import useVoiceText from '@gold/hooks/web/useVoiceText' +import type { AudioItem } from '@gold/config/types' + +// 在组件中使用 +const { getVoiceText } = useVoiceText() + +const audioList: AudioItem[] = [ + { audio_url: 'https://example.com/audio.mp3' } +] + +const transcriptions = await getVoiceText(audioList) +// transcriptions: [{ key: 'url', value: 'transcribed text', audio_url: '...' }] +``` + +#### 类型定义 + +```typescript +import type { + AudioItem, + TranscriptionResult +} from '@gold/config/types' +``` + +## 📦 依赖 + +- `vue`: Vue 3 Composition API +- `axios`: HTTP 请求库(用于 useUserInfo) +- `@gold/config/api`: 公共 API 配置 +- `@gold/config/api/services`: 公共 API 服务创建器 +- `@gold/config/types`: 公共类型定义 + +## 🔧 配置要求 + +确保在应用的 `vite.config.js` 中配置了 `@gold` 别名: + +```javascript +resolve: { + alias: { + '@gold': fileURLToPath(new URL('../../', import.meta.url)) + } +} +``` + diff --git a/frontend/hooks/web/useCache.js b/frontend/hooks/web/useCache.js index ca2c307e0e..e966fda387 100644 --- a/frontend/hooks/web/useCache.js +++ b/frontend/hooks/web/useCache.js @@ -9,6 +9,9 @@ export const CACHE_KEY = { ROLE_ROUTERS: 'roleRouters', USER: 'user', VisitTenantId: 'visitTenantId', + // Token 相关 + ACCESS_TOKEN: 'ACCESS_TOKEN', + REFRESH_TOKEN: 'REFRESH_TOKEN', // 系统设置 IS_DARK: 'isDark', LANG: 'lang', @@ -37,3 +40,29 @@ export const deleteUserCache = () => { wsCache.delete(CACHE_KEY.VisitTenantId) // 注意,不要清理 LoginForm 登录表单 } + +/** + * 删除所有 token 缓存 + * 清空 wsCache 中所有可能的 token 键名变体 + */ +export const deleteTokenCache = () => { + try { + const { wsCache } = useCache() + // 删除所有可能的 token 键名变体(大小写不同) + const tokenKeys = [ + CACHE_KEY.ACCESS_TOKEN, + 'access_token', + CACHE_KEY.REFRESH_TOKEN, + 'refresh_token' + ] + tokenKeys.forEach(key => { + try { + wsCache.delete(key) + } catch (e) { + // 忽略单个键删除失败 + } + }) + } catch (e) { + console.warn('删除 token 缓存失败:', e) + } +} diff --git a/frontend/hooks/web/useUserInfo.js b/frontend/hooks/web/useUserInfo.js new file mode 100644 index 0000000000..bf3f59cda0 --- /dev/null +++ b/frontend/hooks/web/useUserInfo.js @@ -0,0 +1,150 @@ +/** + * 用户信息 Hook + * 封装获取用户信息的逻辑,可在各个应用中复用 + * + * 使用方式: + * import { useUserInfo } from '@gold/hooks/web/useUserInfo' + * + * const { fetchUserInfo, loading, error } = useUserInfo() + * await fetchUserInfo() + */ + +import { ref } from 'vue' +import axios from 'axios' +import { API_BASE } from '@gold/config/api' + +// 获取 token 的工具函数(需要从应用层传入或使用全局配置) +let getTokenFn = null + +/** + * 设置获取 token 的函数 + * @param {Function} fn - 获取 token 的函数 + */ +export function setTokenGetter(fn) { + getTokenFn = fn +} + +/** + * 获取 Authorization Header + * @returns {string} + */ +function getAuthHeader() { + if (getTokenFn) { + const token = getTokenFn() + return token ? `Bearer ${token}` : '' + } + // 如果没有设置 token getter,尝试从常见位置获取 + try { + // 尝试从 sessionStorage 获取 + const manualToken = sessionStorage.getItem('DEV_MANUAL_TOKEN') + if (manualToken) { + return `Bearer ${manualToken}` + } + } catch (e) { + // 忽略错误 + } + return '' +} + +/** + * 用户信息 Hook + * @param {Object} options - 配置选项 + * @param {string} options.baseUrl - API 基础 URL(可选,默认使用 APP_MEMBER) + * @param {Function} options.getToken - 获取 token 的函数(可选) + * @returns {Object} { fetchUserInfo, loading, error, userInfo } + */ +export function useUserInfo(options = {}) { + const loading = ref(false) + const error = ref(null) + const userInfo = ref(null) + + // 如果传入了 getToken 函数,设置它 + if (options.getToken) { + setTokenGetter(options.getToken) + } + + // 确定 API 基础路径 + const baseUrl = options.baseUrl || API_BASE.APP_MEMBER + const apiUrl = `${baseUrl}/user/get` + + /** + * 获取用户信息 + * @returns {Promise} 用户信息对象 + */ + async function fetchUserInfo() { + loading.value = true + error.value = null + + try { + const authHeader = getAuthHeader() + const headers = { + 'Content-Type': 'application/json', + } + + if (authHeader) { + headers.Authorization = authHeader + } + + // 获取 tenant-id(从环境变量或默认值) + const tenantId = + (typeof import.meta !== 'undefined' && import.meta.env?.VITE_TENANT_ID) || + (typeof process !== 'undefined' && process.env?.VITE_TENANT_ID) || + '1' + + if (tenantId) { + headers['tenant-id'] = tenantId + } + + const response = await axios.get(apiUrl, { headers }) + + // 处理响应数据(根据后端返回格式调整) + // 后端通常返回 { code: 0, data: {...}, msg: '...' } 格式 + let data = null + if (response.data) { + // 如果响应有 code 字段,说明是标准格式 + if (typeof response.data.code === 'number') { + // code 为 0 或 200 表示成功 + if (response.data.code === 0 || response.data.code === 200) { + data = response.data.data || response.data + } else { + throw new Error(response.data.msg || response.data.message || '获取用户信息失败') + } + } else { + // 没有 code 字段,直接使用 data + data = response.data.data || response.data + } + } + + if (data) { + userInfo.value = data + return data + } else { + throw new Error('获取用户信息失败:响应数据为空') + } + } catch (err) { + error.value = err + console.error('获取用户信息失败:', err) + throw err + } finally { + loading.value = false + } + } + + return { + fetchUserInfo, + loading, + error, + userInfo, + } +} + +/** + * 便捷函数:直接获取用户信息(不返回响应式状态) + * @param {Object} options - 配置选项 + * @returns {Promise} 用户信息对象 + */ +export async function getUserInfo(options = {}) { + const { fetchUserInfo } = useUserInfo(options) + return await fetchUserInfo() +} + diff --git a/frontend/hooks/web/useVoiceText.ts b/frontend/hooks/web/useVoiceText.ts index 31a8a4832c..6a9af840fa 100644 --- a/frontend/hooks/web/useVoiceText.ts +++ b/frontend/hooks/web/useVoiceText.ts @@ -1,26 +1,33 @@ -import CommonService from '@/api/common' -import type { AudioItem, TranscriptionResult } from '@/src/types/global' +// 使用公共类型定义 +import type { + AudioItem, + TranscriptionResult, + TranscriptionResponse, + TranscriptionData +} from '@gold/config/types' /** - * 转录数据接口 + * API 服务接口(需要从应用层注入) */ -interface TranscriptionData { - file_url?: string - transcripts?: Array<{ text: string }> +interface ApiService { + videoToCharacters: (data: { fileLinkList: string[] }) => Promise<{ data: string }> } +// 全局 API 服务实例(由应用层设置) +let apiServiceInstance: ApiService | null = null + /** - * 响应结果接口 + * 设置 API 服务实例 + * @param service - API 服务对象 */ -interface TranscriptionResponse { - results: Array<{ - transcription_url: string - }> +export function setApiService(service: ApiService) { + apiServiceInstance = service } /** * 将音频列表转换为文本转录 * @param list - 音频项列表 + * @param apiService - API 服务实例(可选,如果已通过 setApiService 设置则不需要) * @returns 转录结果数组 * @throws 当转录过程出错时抛出错误 * @@ -29,9 +36,19 @@ interface TranscriptionResponse { * const transcriptions = await getVoiceText(audioList) * console.log(transcriptions) // [{ key: 'url', value: 'transcribed text' }] */ -export async function getVoiceText(list: AudioItem[]): Promise { +export async function getVoiceText( + list: AudioItem[], + apiService?: ApiService +): Promise { + // 使用传入的 apiService 或全局实例 + const service = apiService || apiServiceInstance + + if (!service) { + throw new Error('getVoiceText: 需要提供 API 服务实例。请使用 setApiService() 设置或传入 apiService 参数') + } + // 调用API将视频转换为文本 - const ret = await CommonService.videoToCharacters({ + const ret = await service.videoToCharacters({ fileLinkList: list.map(item => item.audio_url), }) @@ -68,19 +85,29 @@ export async function getVoiceText(list: AudioItem[]): Promise Promise + getVoiceText: (list: AudioItem[], apiService?: ApiService) => Promise } /** * 语音文本转换 Hook + * @param apiService - API 服务实例(可选,如果已通过 setApiService 设置则不需要) * @returns 包含 getVoiceText 方法的对象 * * @example + * // 方式一:使用全局设置的 API 服务 + * setApiService(myApiService) * const { getVoiceText } = useVoiceText() * const result = await getVoiceText(audioList) + * + * @example + * // 方式二:传入 API 服务实例 + * const { getVoiceText } = useVoiceText() + * const result = await getVoiceText(audioList, myApiService) */ -export default function useVoiceText(): UseVoiceTextReturn { - return { getVoiceText } +export default function useVoiceText(apiService?: ApiService): UseVoiceTextReturn { + return { + getVoiceText: (list: AudioItem[]) => getVoiceText(list, apiService) + } } diff --git a/frontend/package.json b/frontend/package.json index 88fa1dd2fd..7540e34327 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "dependencies": { "localforage": "^1.10.0", "unocss": "^66.5.4", + "axios": "^1.12.2", "web-storage-cache": "^1.1.1" } } diff --git a/frontend/utils/README.md b/frontend/utils/README.md new file mode 100644 index 0000000000..4501b2aec6 --- /dev/null +++ b/frontend/utils/README.md @@ -0,0 +1,108 @@ +# 全局工具模块 (Monorepo Utils) + +此目录包含可在 monorepo 各个应用中复用的全局工具函数。 + +## 📁 目录结构 + +``` +utils/ +├── token-manager.js # Token 统一管理工具 +└── README.md # 本文件 +``` + +## 🚀 使用方式 + +### Token 管理 (`token-manager.js`) + +在应用中使用全局 token 管理工具: + +```javascript +// 导入 token 管理工具 +import { + getToken, + setToken, + clearAllTokens, + getAuthHeader +} from '@gold/utils/token-manager' + +// 获取 token +const token = getToken() + +// 设置 token +setToken({ + accessToken: 'xxx', + refreshToken: 'yyy' +}) + +// 获取 Authorization Header +const header = getAuthHeader() // Bearer xxx + +// 清空所有 token +clearAllTokens() +``` + +## 📝 API 说明 + +### Token 管理函数 + +#### `getToken()` +获取访问令牌(按优先级读取) + +**返回值:** `string` - token 字符串或空字符串 + +#### `getAccessToken()` +获取访问令牌(别名,兼容旧代码) + +**返回值:** `string` - token 字符串或空字符串 + +#### `getRefreshToken()` +获取刷新令牌 + +**返回值:** `string | null` - 刷新令牌或 null + +#### `setToken(tokens)` +设置访问令牌和刷新令牌 + +**参数:** +- `tokens.accessToken` (string, 可选): 访问令牌 +- `tokens.refreshToken` (string, 可选): 刷新令牌 + +#### `clearAllTokens()` +清除所有 token(包括 dev token、access token、refresh token) + +#### `removeToken()` +删除 token(别名,调用 `clearAllTokens()`) + +#### `getAuthHeader()` +获取完整的 Authorization Header 值 + +**返回值:** `string` - `Bearer token` 或空字符串 + +#### `setDevToken(token)` +设置手动输入的 dev token(用于开发测试) + +**参数:** `token` (string) - dev token + +#### `getDevToken()` +获取手动输入的 dev token + +**返回值:** `string` - dev token 或空字符串 + +## 🔄 Token 存储优先级 + +1. **手动输入的 dev token** (sessionStorage) - 最高优先级 +2. **正式登录的 token** (wsCache/localStorage) +3. **环境变量 VITE_DEV_TOKEN** - 兜底 + +## 📦 在 Vite 配置中使用 + +确保在 `vite.config.js` 中配置了 `@gold` 别名: + +```javascript +resolve: { + alias: { + '@gold': fileURLToPath(new URL('../../', import.meta.url)) + } +} +``` + diff --git a/frontend/utils/TOKEN_STORAGE.md b/frontend/utils/TOKEN_STORAGE.md new file mode 100644 index 0000000000..31083c6260 --- /dev/null +++ b/frontend/utils/TOKEN_STORAGE.md @@ -0,0 +1,160 @@ +# Token 存储位置说明 + +## 📍 Token 存储位置总览 + +Token 在项目中有 **3 个存储位置**,按读取优先级排序: + +### 1. **Dev Token(开发手动输入的 token)** +- **存储位置**:`sessionStorage` +- **键名**:`DEV_MANUAL_TOKEN` +- **设置方式**:`setDevToken(token)` +- **特点**: + - 优先级最高(读取时优先使用) + - 关闭浏览器标签页后自动清除 + - 用于开发测试 + +**浏览器查看方式:** +```javascript +// 在浏览器控制台执行 +sessionStorage.getItem('DEV_MANUAL_TOKEN') +``` + +### 2. **正式登录的 Token(主要存储)** +- **存储位置**:`localStorage`(通过 WebStorageCache 封装) +- **键名**: + - `ACCESS_TOKEN` 或 `access_token`(访问令牌) + - `REFRESH_TOKEN` 或 `refresh_token`(刷新令牌) +- **设置方式**:`setToken({ accessToken, refreshToken })` +- **特点**: + - 持久化存储(关闭浏览器后仍然存在) + - 使用 `WebStorageCache` 库管理 + - 支持大小写不同的键名变体(兼容性) + +**浏览器查看方式:** +```javascript +// 在浏览器控制台执行 +localStorage.getItem('ACCESS_TOKEN') +localStorage.getItem('REFRESH_TOKEN') +// 或者 +localStorage.getItem('access_token') +localStorage.getItem('refresh_token') +``` + +**实际存储结构:** +``` +localStorage: + ├── ACCESS_TOKEN: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + └── REFRESH_TOKEN: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +### 3. **环境变量 Token(兜底)** +- **存储位置**:代码中(不存储在浏览器) +- **变量名**:`VITE_DEV_TOKEN` +- **设置方式**:`.env` 文件或环境变量 +- **特点**: + - 只在代码中读取,不写入浏览器存储 + - 优先级最低(前两者都没有时才使用) + - 用于开发环境默认配置 + +## 🔄 Token 读取优先级 + +当调用 `getToken()` 时,按以下顺序查找: + +``` +1. sessionStorage['DEV_MANUAL_TOKEN'] ← 最高优先级 + ↓ (如果没有) +2. localStorage['ACCESS_TOKEN'] 或 localStorage['access_token'] + ↓ (如果没有) +3. import.meta.env.VITE_DEV_TOKEN ← 最低优先级 +``` + +## 📝 代码示例 + +### 设置 Token + +```javascript +import { setToken, setDevToken } from '@gold/utils/token-manager' + +// 设置正式登录的 token(存储到 localStorage) +setToken({ + accessToken: 'xxx', + refreshToken: 'yyy' +}) + +// 设置开发 token(存储到 sessionStorage) +setDevToken('dev-token-123') +``` + +### 读取 Token + +```javascript +import { getToken } from '@gold/utils/token-manager' + +// 自动按优先级读取 +const token = getToken() +``` + +### 清除 Token + +```javascript +import { clearAllTokens } from '@gold/utils/token-manager' + +// 清除所有位置的 token +clearAllTokens() +// 会清除: +// 1. sessionStorage['DEV_MANUAL_TOKEN'] +// 2. localStorage['ACCESS_TOKEN'] 和 'access_token' +// 3. localStorage['REFRESH_TOKEN'] 和 'refresh_token' +``` + +## 🔍 在浏览器中查看 + +### Chrome DevTools + +1. **打开 DevTools** (F12) +2. **Application 标签页** +3. **Storage 部分**: + - **Local Storage** → 查看 `ACCESS_TOKEN`、`REFRESH_TOKEN` + - **Session Storage** → 查看 `DEV_MANUAL_TOKEN` + +### 控制台命令 + +```javascript +// 查看所有 token +console.log('Dev Token:', sessionStorage.getItem('DEV_MANUAL_TOKEN')) +console.log('Access Token:', localStorage.getItem('ACCESS_TOKEN')) +console.log('Refresh Token:', localStorage.getItem('REFRESH_TOKEN')) + +// 查看所有 localStorage +console.table(localStorage) + +// 查看所有 sessionStorage +console.table(sessionStorage) +``` + +## ⚠️ 注意事项 + +1. **WebStorageCache 封装**: + - `useCache()` 默认使用 `localStorage` + - 通过 `WebStorageCache` 库管理,支持过期时间等功能 + - 实际存储位置仍然是 `localStorage` + +2. **键名大小写**: + - 代码中统一使用 `ACCESS_TOKEN` 和 `REFRESH_TOKEN`(大写) + - 但为了兼容,也支持 `access_token` 和 `refresh_token`(小写) + - 读取时会尝试所有变体 + +3. **清除逻辑**: + - `clearAllTokens()` 会清除所有位置的 token + - 包括 sessionStorage、localStorage 的所有变体键名 + - 确保完全清除,避免残留 + +## 📊 存储位置总结表 + +| Token 类型 | 存储位置 | 键名 | 持久化 | 优先级 | +|-----------|---------|------|--------|--------| +| Dev Token | sessionStorage | `DEV_MANUAL_TOKEN` | ❌ 关闭标签页清除 | 1 (最高) | +| Access Token | localStorage | `ACCESS_TOKEN` / `access_token` | ✅ 持久化 | 2 | +| Refresh Token | localStorage | `REFRESH_TOKEN` / `refresh_token` | ✅ 持久化 | 2 | +| Env Token | 代码中 | `VITE_DEV_TOKEN` | N/A | 3 (最低) | + diff --git a/frontend/utils/token-manager.js b/frontend/utils/token-manager.js new file mode 100644 index 0000000000..6c2d2e7f25 --- /dev/null +++ b/frontend/utils/token-manager.js @@ -0,0 +1,215 @@ +import { useCache, CACHE_KEY, deleteTokenCache } from '../hooks/web/useCache' + +/** + * Token 统一管理模块(Monorepo 全局工具) + * + * 这是项目中唯一的 token 管理入口,所有 token 操作都应该通过此模块进行。 + * + * Token 存储优先级(读取顺序): + * 1. 手动输入的 dev token (sessionStorage) + * 2. 正式登录的 token (wsCache/localStorage) + * 3. 环境变量 VITE_DEV_TOKEN + * + * Token 存储位置: + * - ACCESS_TOKEN/access_token: 访问令牌(wsCache/localStorage) + * - REFRESH_TOKEN/refresh_token: 刷新令牌(wsCache/localStorage) + * - DEV_MANUAL_TOKEN: 开发手动输入的token(sessionStorage) + */ + +// ==================== 常量定义 ==================== +const DEV_MANUAL_TOKEN_KEY = 'DEV_MANUAL_TOKEN' + +// Token 键名变体(支持大小写不同) +const TOKEN_KEYS = { + ACCESS: [CACHE_KEY.ACCESS_TOKEN, 'access_token'], + REFRESH: [CACHE_KEY.REFRESH_TOKEN, 'refresh_token'] +} + +// ==================== 缓存实例管理 ==================== +let wsCache = null + +/** + * 获取 wsCache 实例(延迟初始化,避免模块加载时出错) + * @returns {Object} wsCache 实例 + */ +function getCache() { + if (!wsCache) { + try { + wsCache = useCache().wsCache + } catch (e) { + console.warn('初始化 wsCache 失败:', e) + // 返回一个安全的空对象,避免后续调用出错 + wsCache = { + get: () => null, + set: () => {}, + delete: () => {} + } + } + } + return wsCache +} + +/** + * 获取完整的 Authorization Header 值 + * @returns {string} Bearer token 或空字符串 + */ +export function getAuthHeader() { + const token = getToken() + return token ? `Bearer ${token}` : '' +} + +/** + * 获取 token + * @returns {string} token 字符串 + */ +export function getToken() { + // 1. 优先使用手动输入的 dev token + const manualToken = sessionStorage.getItem(DEV_MANUAL_TOKEN_KEY) + if (manualToken) { + return manualToken + } + + // 2. 使用正式登录的 token(从 wsCache 读取) + try { + const cache = getCache() + // 尝试所有可能的键名变体 + for (const key of TOKEN_KEYS.ACCESS) { + const accessToken = cache.get(key) + if (accessToken) { + return accessToken + } + } + } catch (e) { + console.warn('获取 wsCache 失败:', e) + } + + // 3. 兜底:环境变量中的 token + const envToken = import.meta?.env?.VITE_DEV_TOKEN + if (envToken) { + return envToken + } + + return '' +} + +/** + * 设置手动输入的 dev token + * @param {string} token + */ +export function setDevToken(token) { + if (token) { + sessionStorage.setItem(DEV_MANUAL_TOKEN_KEY, token) + } else { + sessionStorage.removeItem(DEV_MANUAL_TOKEN_KEY) + } +} + +/** + * 获取手动输入的 dev token(用于显示) + * @returns {string} + */ +export function getDevToken() { + return sessionStorage.getItem(DEV_MANUAL_TOKEN_KEY) || '' +} + +/** + * 设置访问令牌和刷新令牌 + * @param {Object} tokens - token 对象 + * @param {string} tokens.accessToken - 访问令牌 + * @param {string} tokens.refreshToken - 刷新令牌 + */ +export function setToken(tokens) { + if (!tokens || (!tokens.accessToken && !tokens.refreshToken)) { + console.warn('setToken: token 参数无效', tokens) + return + } + + try { + const cache = getCache() + if (tokens.accessToken) { + cache.set(CACHE_KEY.ACCESS_TOKEN, tokens.accessToken) + } + if (tokens.refreshToken) { + cache.set(CACHE_KEY.REFRESH_TOKEN, tokens.refreshToken) + } + } catch (e) { + console.error('设置 token 失败:', e) + } +} + +/** + * 获取刷新令牌 + * @returns {string|null} 刷新令牌或 null + */ +export function getRefreshToken() { + try { + const cache = getCache() + // 尝试所有可能的键名变体 + for (const key of TOKEN_KEYS.REFRESH) { + const token = cache.get(key) + if (token) { + return token + } + } + } catch (e) { + console.warn('获取 refresh token 失败:', e) + } + return null +} + +/** + * 清除所有 token + * 清空所有可能的 token 存储位置,确保完全清除 + */ +export function clearAllTokens() { + try { + // 1. 清空 sessionStorage 中的 dev token + sessionStorage.removeItem(DEV_MANUAL_TOKEN_KEY) + } catch (e) { + console.warn('清除 sessionStorage token 失败:', e) + } + + try { + // 2. 清空 wsCache (localStorage) 中的所有可能的 token 键名 + // 使用统一的 deleteTokenCache 函数,保持与 useCache 的一致性 + deleteTokenCache() + } catch (e) { + console.warn('清除 wsCache token 失败:', e) + } + + try { + // 3. 清空 localStorage 中可能的 token(兜底处理) + // 注意:wsCache 默认使用 localStorage,但为了确保清除,也直接操作 localStorage + const localStorageKeys = [ + ...TOKEN_KEYS.ACCESS, + ...TOKEN_KEYS.REFRESH, + DEV_MANUAL_TOKEN_KEY + ] + localStorageKeys.forEach(key => { + try { + localStorage.removeItem(key) + } catch (e) { + // 忽略单个键删除失败 + } + }) + } catch (e) { + console.warn('清除 localStorage token 失败:', e) + } +} + +/** + * 删除 token(别名,兼容旧代码) + * @deprecated 使用 clearAllTokens() 代替 + */ +export function removeToken() { + clearAllTokens() +} + +/** + * 获取访问令牌(别名,兼容旧代码) + * @returns {string} token 字符串 + */ +export function getAccessToken() { + return getToken() +} + diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/userprompt/UserPromptDO.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/userprompt/UserPromptDO.java new file mode 100644 index 0000000000..83899be861 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/userprompt/UserPromptDO.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.ai.dal.dataobject.userprompt; + +import lombok.*; +import java.util.*; +import com.baomidou.mybatisplus.annotation.*; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; + +/** + * 用户提示词 DO + * + * @author 芋道源码 + */ +@TableName("ai_user_prompt") +@KeySequence("ai_user_prompt_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserPromptDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 用户编号 + */ + private Long userId; + /** + * 提示词名称 + */ + private String name; + /** + * 提示词内容 + */ + private String content; + /** + * 分类/标签 + */ + private String category; + /** + * 是否公开(0-私有,1-公开) + */ + private Boolean isPublic; + /** + * 排序 + */ + private Integer sort; + /** + * 使用次数 + */ + private Integer useCount; + /** + * 状态(0-禁用,1-启用) + */ + private Integer status; + /** + * 备注 + */ + private String remark; + + +} diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/enums/ErrorCodeConstants.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/enums/ErrorCodeConstants.java index 8a8a388324..920ed799a3 100644 --- a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/enums/ErrorCodeConstants.java +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/enums/ErrorCodeConstants.java @@ -60,9 +60,9 @@ public interface ErrorCodeConstants { // ========== AI 工具 1-040-010-000 ========== ErrorCode TOOL_NOT_EXISTS = new ErrorCode(1_040_010_000, "工具不存在"); ErrorCode TOOL_NAME_NOT_EXISTS = new ErrorCode(1_040_010_001, "工具({})找不到 Bean"); + ErrorCode USER_PROMPT_NOT_EXISTS = new ErrorCode(1_040_010_002, "用户提示词不存在"); // ========== AI 工作流 1-040-011-000 ========== ErrorCode WORKFLOW_NOT_EXISTS = new ErrorCode(1_040_011_000, "工作流不存在"); ErrorCode WORKFLOW_CODE_EXISTS = new ErrorCode(1_040_011_001, "工作流标识已存在"); - } diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/controller/UserPromptController.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/controller/UserPromptController.java new file mode 100644 index 0000000000..10f96cfdb1 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/controller/UserPromptController.java @@ -0,0 +1,104 @@ +package cn.iocoder.yudao.module.ai.userprompt.controller; + +import org.springframework.web.bind.annotation.*; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.security.access.prepost.PreAuthorize; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Operation; + +import jakarta.validation.constraints.*; +import jakarta.validation.*; +import jakarta.servlet.http.*; +import java.util.*; +import java.io.IOException; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; + +import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog; +import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.*; + +import cn.iocoder.yudao.module.ai.userprompt.vo.*; +import cn.iocoder.yudao.module.ai.dal.dataobject.userprompt.UserPromptDO; +import cn.iocoder.yudao.module.ai.userprompt.service.UserPromptService; + +@Tag(name = "AI - 用户提示词") +@RestController +@RequestMapping("/ai/user-prompt") +@Validated +public class UserPromptController { + + @Resource + private UserPromptService userPromptService; + + @PostMapping("/create") + @Operation(summary = "创建用户提示词") + @PreAuthorize("@ss.hasPermission('ai:user-prompt:create')") + public CommonResult createUserPrompt(@Valid @RequestBody UserPromptSaveReqVO createReqVO) { + return success(userPromptService.createUserPrompt(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新用户提示词") + @PreAuthorize("@ss.hasPermission('ai:user-prompt:update')") + public CommonResult updateUserPrompt(@Valid @RequestBody UserPromptSaveReqVO updateReqVO) { + userPromptService.updateUserPrompt(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除用户提示词") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai:user-prompt:delete')") + public CommonResult deleteUserPrompt(@RequestParam("id") Long id) { + userPromptService.deleteUserPrompt(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Parameter(name = "ids", description = "编号", required = true) + @Operation(summary = "批量删除用户提示词") + @PreAuthorize("@ss.hasPermission('ai:user-prompt:delete')") + public CommonResult deleteUserPromptList(@RequestParam("ids") List ids) { + userPromptService.deleteUserPromptListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得用户提示词") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai:user-prompt:query')") + public CommonResult getUserPrompt(@RequestParam("id") Long id) { + UserPromptDO userPrompt = userPromptService.getUserPrompt(id); + return success(BeanUtils.toBean(userPrompt, UserPromptRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得用户提示词分页") + @PreAuthorize("@ss.hasPermission('ai:user-prompt:query')") + public CommonResult> getUserPromptPage(@Valid UserPromptPageReqVO pageReqVO) { + PageResult pageResult = userPromptService.getUserPromptPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, UserPromptRespVO.class)); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出用户提示词 Excel") + @PreAuthorize("@ss.hasPermission('ai:user-prompt:export')") + @ApiAccessLog(operateType = EXPORT) + public void exportUserPromptExcel(@Valid UserPromptPageReqVO pageReqVO, + HttpServletResponse response) throws IOException { + pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + List list = userPromptService.getUserPromptPage(pageReqVO).getList(); + // 导出 Excel + ExcelUtils.write(response, "用户提示词.xls", "数据", UserPromptRespVO.class, + BeanUtils.toBean(list, UserPromptRespVO.class)); + } + +} diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/mapper/UserPromptMapper.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/mapper/UserPromptMapper.java new file mode 100644 index 0000000000..7c1173c52a --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/mapper/UserPromptMapper.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.ai.userprompt.mapper; + +import java.util.*; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.ai.dal.dataobject.userprompt.UserPromptDO; +import org.apache.ibatis.annotations.Mapper; +import cn.iocoder.yudao.module.ai.userprompt.vo.*; + +/** + * 用户提示词 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface UserPromptMapper extends BaseMapperX { + + default PageResult selectPage(UserPromptPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(UserPromptDO::getUserId, reqVO.getUserId()) + .likeIfPresent(UserPromptDO::getName, reqVO.getName()) + .eqIfPresent(UserPromptDO::getContent, reqVO.getContent()) + .eqIfPresent(UserPromptDO::getCategory, reqVO.getCategory()) + .eqIfPresent(UserPromptDO::getIsPublic, reqVO.getIsPublic()) + .eqIfPresent(UserPromptDO::getSort, reqVO.getSort()) + .eqIfPresent(UserPromptDO::getUseCount, reqVO.getUseCount()) + .eqIfPresent(UserPromptDO::getStatus, reqVO.getStatus()) + .eqIfPresent(UserPromptDO::getRemark, reqVO.getRemark()) + .betweenIfPresent(UserPromptDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(UserPromptDO::getId)); + } + +} diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/service/UserPromptService.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/service/UserPromptService.java new file mode 100644 index 0000000000..7064611afb --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/service/UserPromptService.java @@ -0,0 +1,62 @@ +package cn.iocoder.yudao.module.ai.userprompt.service; + +import java.util.*; +import jakarta.validation.*; +import cn.iocoder.yudao.module.ai.userprompt.vo.*; +import cn.iocoder.yudao.module.ai.dal.dataobject.userprompt.UserPromptDO; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.pojo.PageParam; + +/** + * 用户提示词 Service 接口 + * + * @author 芋道源码 + */ +public interface UserPromptService { + + /** + * 创建用户提示词 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createUserPrompt(@Valid UserPromptSaveReqVO createReqVO); + + /** + * 更新用户提示词 + * + * @param updateReqVO 更新信息 + */ + void updateUserPrompt(@Valid UserPromptSaveReqVO updateReqVO); + + /** + * 删除用户提示词 + * + * @param id 编号 + */ + void deleteUserPrompt(Long id); + + /** + * 批量删除用户提示词 + * + * @param ids 编号 + */ + void deleteUserPromptListByIds(List ids); + + /** + * 获得用户提示词 + * + * @param id 编号 + * @return 用户提示词 + */ + UserPromptDO getUserPrompt(Long id); + + /** + * 获得用户提示词分页 + * + * @param pageReqVO 分页查询 + * @return 用户提示词分页 + */ + PageResult getUserPromptPage(UserPromptPageReqVO pageReqVO); + +} diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/service/UserPromptServiceImpl.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/service/UserPromptServiceImpl.java new file mode 100644 index 0000000000..1ed720ddec --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/service/UserPromptServiceImpl.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.ai.userprompt.service; + +import cn.hutool.core.collection.CollUtil; +import org.springframework.stereotype.Service; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import cn.iocoder.yudao.module.ai.userprompt.vo.*; +import cn.iocoder.yudao.module.ai.dal.dataobject.userprompt.UserPromptDO; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; + +import cn.iocoder.yudao.module.ai.userprompt.mapper.UserPromptMapper; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.diffList; +import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.*; + +/** + * 用户提示词 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class UserPromptServiceImpl implements UserPromptService { + + @Resource + private UserPromptMapper userPromptMapper; + + @Override + public Long createUserPrompt(UserPromptSaveReqVO createReqVO) { + // 插入 + UserPromptDO userPrompt = BeanUtils.toBean(createReqVO, UserPromptDO.class); + userPromptMapper.insert(userPrompt); + + // 返回 + return userPrompt.getId(); + } + + @Override + public void updateUserPrompt(UserPromptSaveReqVO updateReqVO) { + // 校验存在 + validateUserPromptExists(updateReqVO.getId()); + // 更新 + UserPromptDO updateObj = BeanUtils.toBean(updateReqVO, UserPromptDO.class); + userPromptMapper.updateById(updateObj); + } + + @Override + public void deleteUserPrompt(Long id) { + // 校验存在 + validateUserPromptExists(id); + // 删除 + userPromptMapper.deleteById(id); + } + + @Override + public void deleteUserPromptListByIds(List ids) { + // 删除 + userPromptMapper.deleteByIds(ids); + } + + + private void validateUserPromptExists(Long id) { + if (userPromptMapper.selectById(id) == null) { + throw exception(USER_PROMPT_NOT_EXISTS); + } + } + + @Override + public UserPromptDO getUserPrompt(Long id) { + return userPromptMapper.selectById(id); + } + + @Override + public PageResult getUserPromptPage(UserPromptPageReqVO pageReqVO) { + return userPromptMapper.selectPage(pageReqVO); + } + +} diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/vo/UserPromptPageReqVO.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/vo/UserPromptPageReqVO.java new file mode 100644 index 0000000000..f2f3b97ff4 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/vo/UserPromptPageReqVO.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.ai.userprompt.vo; + +import lombok.*; +import java.util.*; +import io.swagger.v3.oas.annotations.media.Schema; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import org.springframework.format.annotation.DateTimeFormat; +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 用户提示词分页 Request VO") +@Data +public class UserPromptPageReqVO extends PageParam { + + @Schema(description = "用户编号", example = "12926") + private Long userId; + + @Schema(description = "提示词名称", example = "张三") + private String name; + + @Schema(description = "提示词内容") + private String content; + + @Schema(description = "分类/标签") + private String category; + + @Schema(description = "是否公开(0-私有,1-公开)") + private Boolean isPublic; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "使用次数", example = "22185") + private Integer useCount; + + @Schema(description = "状态(0-禁用,1-启用)", example = "1") + private Integer status; + + @Schema(description = "备注", example = "你说的对") + private String remark; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/vo/UserPromptRespVO.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/vo/UserPromptRespVO.java new file mode 100644 index 0000000000..b4ec2b249d --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/vo/UserPromptRespVO.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.module.ai.userprompt.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; +import java.util.*; +import org.springframework.format.annotation.DateTimeFormat; +import java.time.LocalDateTime; +import cn.idev.excel.annotation.*; + +@Schema(description = "管理后台 - 用户提示词 Response VO") +@Data +@ExcelIgnoreUnannotated +public class UserPromptRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "12482") + @ExcelProperty("编号") + private Long id; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "12926") + @ExcelProperty("用户编号") + private Long userId; + + @Schema(description = "提示词名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三") + @ExcelProperty("提示词名称") + private String name; + + @Schema(description = "提示词内容", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("提示词内容") + private String content; + + @Schema(description = "分类/标签") + @ExcelProperty("分类/标签") + private String category; + + @Schema(description = "是否公开(0-私有,1-公开)", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("是否公开(0-私有,1-公开)") + private Boolean isPublic; + + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("排序") + private Integer sort; + + @Schema(description = "使用次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "22185") + @ExcelProperty("使用次数") + private Integer useCount; + + @Schema(description = "状态(0-禁用,1-启用)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @ExcelProperty("状态(0-禁用,1-启用)") + private Integer status; + + @Schema(description = "备注", example = "你说的对") + @ExcelProperty("备注") + private String remark; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("创建时间") + private LocalDateTime createTime; + +} diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/vo/UserPromptSaveReqVO.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/vo/UserPromptSaveReqVO.java new file mode 100644 index 0000000000..a8238f3017 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/vo/UserPromptSaveReqVO.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.ai.userprompt.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; +import java.util.*; +import jakarta.validation.constraints.*; + +@Schema(description = "管理后台 - 用户提示词新增/修改 Request VO") +@Data +public class UserPromptSaveReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "12482") + private Long id; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "12926") + @NotNull(message = "用户编号不能为空") + private Long userId; + + @Schema(description = "提示词名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三") + @NotEmpty(message = "提示词名称不能为空") + private String name; + + @Schema(description = "提示词内容", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "提示词内容不能为空") + private String content; + + @Schema(description = "分类/标签") + private String category; + + @Schema(description = "是否公开(0-私有,1-公开)", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "是否公开(0-私有,1-公开)不能为空") + private Boolean isPublic; + + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "排序不能为空") + private Integer sort; + + @Schema(description = "使用次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "22185") + @NotNull(message = "使用次数不能为空") + private Integer useCount; + + @Schema(description = "状态(0-禁用,1-启用)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "状态(0-禁用,1-启用)不能为空") + private Integer status; + + @Schema(description = "备注", example = "你说的对") + private String remark; + +}