This commit is contained in:
2025-11-10 00:59:40 +08:00
parent 78c46aed71
commit bac96fcbe6
76 changed files with 8726 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

View File

@@ -0,0 +1,6 @@
VITE_TIKHUB_TIKTOK_TOKEN=lcsEHcw6x1awim+hDZyN9xH2EgXKd5NcvTvzvlqtav/qvGiFH0rSdQzODQ==
VITE_TIKHUB_XHS_TOKEN=pZM4CJ484F+rxlyfLL+XmAzwMCKXVb5l2x7WsUOyCMu1rm61FiDcRQmPCQ==
VITE_DEV_TOKEN=a498c8db4b4e4dbfb9e28ad2606713ec
VITE_BASE_URL=/webApi
# 接口地址
VITE_API_URL=/admin-api

View File

@@ -0,0 +1,5 @@
VITE_TIKHUB_TIKTOK_TOKEN=lcsEHcw6x1awim+hDZyN9xH2EgXKd5NcvTvzvlqtav/qvGiFH0rSdQzODQ==
VITE_TIKHUB_XHS_TOKEN=pZM4CJ484F+rxlyfLL+XmAzwMCKXVb5l2x7WsUOyCMu1rm61FiDcRQmPCQ==
VITE_BASE_URL=/webApi
# 接口地址
VITE_API_URL=/admin-api

1
frontend/app/web-gold/.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

30
frontend/app/web-gold/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

View File

@@ -0,0 +1,9 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}

View File

@@ -0,0 +1,37 @@
FROM node:20-alpine AS builder
# 使用 corepack 管理 pnpm保持与仓库锁一致
ENV PNPM_HOME=/root/.local/share/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN corepack enable
WORKDIR /workspace
# docker build -f app/web-gold/Dockerfile -t web-gold-nginx .
# docker run -d --name web-gold -p 8088:8088 web-gold-nginx
# 复制整个仓库(建议在仓库根目录作为构建上下文执行:
# docker build -f app/web-gold/Dockerfile -t web-gold-nginx .
#
COPY . .
# 安装依赖(工作区模式),严格锁定版本
RUN pnpm -w install --frozen-lockfile
# 构建前端应用(工作区内的 app/web-gold
WORKDIR /workspace/app/web-gold
RUN pnpm build
FROM nginx:alpine
# 覆盖默认站点配置(支持 Vite SPA 刷新与可选反代)
COPY app/web-gold/nginx.conf /etc/nginx/conf.d/default.conf
# 拷贝构建产物到 Nginx 根目录
COPY --from=builder /workspace/app/web-gold/dist /usr/share/nginx/html
EXPOSE 8088
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,28 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default defineConfig([
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
{
languageOptions: {
globals: {
...globals.browser,
},
},
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
...pluginOxlint.configs['flat/recommended'],
skipFormatting,
])

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,23 @@
server {
listen 8088;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Vite SPA刷新/直达路由回退到 index.html
location / {
try_files $uri $uri/ /index.html;
}
# 如需在生产环境继续代理后端,可在此追加;示例:
# location /webApi/ {
# 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_pass http://8.155.172.147:9900/;
# }
}

View File

@@ -0,0 +1,56 @@
{
"name": "web-gold",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite --mode development",
"build": "vite build",
"preview": "vite preview",
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
"lint:eslint": "eslint . --fix",
"lint": "run-s lint:*",
"format": "prettier --write src/"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@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",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"qs": "^6.14.0",
"vue": "^3.5.22",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@prettier/plugin-oxc": "^0.0.4",
"@tailwindcss/postcss": "^4.1.14",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.1.1",
"@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.33.0",
"eslint-plugin-oxlint": "~1.11.0",
"eslint-plugin-vue": "~10.4.0",
"globals": "^16.3.0",
"normalize.css": "^8.0.1",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.11.0",
"postcss": "^8.5.6",
"prettier": "3.6.2",
"tailwindcss": "^4.1.14",
"typescript": "^5.7.3",
"vite": "^7.1.7",
"vite-plugin-vue-devtools": "^8.0.2",
"vue-tsc": "^2.1.28"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,88 @@
<script setup>
import { RouterView } from 'vue-router'
import { ref, onMounted } from 'vue'
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'
function readCssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || undefined
}
const themeToken = ref({
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: '#3B82F6',
colorInfo: '#1A66E0',
colorBgBase: '#0D0D0D',
colorBgContainer: '#1A1A1A',
colorTextBase: '#F2F2F2',
colorTextSecondary: '#CCCCCC',
colorBorder: '#333333',
borderRadius: 6,
}
})
onMounted(() => {
// 运行时从 :root 读取,若存在则覆盖默认值
const next = { ...themeToken.value.token }
next.colorPrimary = readCssVar('--color-primary') || next.colorPrimary
next.colorInfo = readCssVar('--color-blue') || next.colorInfo
next.colorBgBase = readCssVar('--color-bg') || next.colorBgBase
next.colorBgContainer = readCssVar('--color-surface') || next.colorBgContainer
next.colorTextBase = readCssVar('--color-text') || next.colorTextBase
next.colorTextSecondary = readCssVar('--color-text-secondary') || next.colorTextSecondary
next.colorBorder = readCssVar('--color-border') || next.colorBorder
themeToken.value = { algorithm: theme.darkAlgorithm, token: next }
})
</script>
<template>
<a-config-provider :theme="themeToken">
<div class="app-shell">
<SvgSprite />
<TopNav />
<div class="app-body">
<SidebarNav />
<div class="app-content">
<main class="content-scroll">
<keep-alive>
<RouterView />
</keep-alive>
</main>
<footer class="py-6 text-xs text-center text-gray-500">
v0.1 · API 正常 · © 2025 金牌内容大师
</footer>
</div>
</div>
</div>
</a-config-provider>
</template>
<style scoped>
.app-shell {
min-height: 100vh;
background: var(--color-bg);
}
/* 顶部固定,下面主体需要留出空间 */
.app-body {
padding-top: 70px; /* 与 TopNav 高度对齐 */
display: grid;
grid-template-columns: 220px 1fr; /* 左侧固定宽度侧边栏 */
}
.app-content {
min-height: calc(100vh - 70px);
display: flex;
flex-direction: column;
}
.content-scroll {
flex: 1 1 auto;
overflow: auto; /* 右侧内容区域滚动 */
padding: 0 16px 0 16px;
}
</style>

View File

@@ -0,0 +1,237 @@
import api from '@/api/http'
import { setToken, getRefreshToken } from '@/utils/auth'
const SERVER_BASE = import.meta.env.VITE_BASE_URL + '/app-api/member'
/**
* 保存 token 的辅助函数
* @param {Object} info - 包含 accessToken 和 refreshToken 的对象
*/
function saveTokens(info) {
if (info?.accessToken || info?.refreshToken) {
setToken({
accessToken: info.accessToken || '',
refreshToken: info.refreshToken || '',
})
}
}
/**
* 响应拦截(可选):统一错误处理
*/
// api.interceptors.response.use(
// (resp) => resp,
// (err) => {
// // 统一错误日志/提示
// return Promise.reject(err);
// }
// );
/**
* 短信场景枚举(请与后端配置保持一致)
* - MEMBER_LOGIN: 会员短信登录场景
* - MEMBER_UPDATE_PASSWORD: 已登录用户修改密码(短信校验)场景
* - MEMBER_RESET_PASSWORD: 未登录用户忘记密码重置(短信校验)场景
* 如有"注册"独立场景可在此添加MEMBER_REGISTER: 13
*/
export const SMS_SCENE = {
MEMBER_LOGIN: 1,
MEMBER_UPDATE_PASSWORD: 3,
MEMBER_RESET_PASSWORD: 4,
};
/**
* 短信模板编码常量
*/
export const SMS_TEMPLATE_CODE = {
USER_REGISTER: 'muye-user-code', // 用户注册模板编码
};
/**
* 账号密码登录
* POST /member/auth/login
*
* @param {string} mobile - 手机号(必填)
* @param {string} password - 密码(必填,长度 4-16
* @returns {Promise<Object>} data.data: { accessToken, refreshToken, expiresTime, userInfo }
*/
export async function loginByPassword(mobile, password) {
const { data } = await api.post(`${SERVER_BASE}/auth/login`, { mobile, password });
const info = data || {};
saveTokens(info);
return info;
}
/**
* 发送短信验证码
* POST /member/auth/send-sms-code
*
* @param {string} mobile - 手机号(登录/忘记密码等需要;修改密码场景可不传)
* @param {number} scene - 短信场景(见 SMS_SCENE
* @param {string} templateCode - 模板编码(可选,如 'muye-user-code'
* @returns {Promise<Object>} 后端通用响应结构
*/
export async function sendSmsCode(mobile, scene, templateCode) {
const body = { scene };
if (mobile) body.mobile = mobile; // 修改密码场景后端会根据当前登录用户自动填充手机号
if (templateCode) body.templateCode = templateCode; // 添加模板编码参数
const { data } = await api.post(`${SERVER_BASE}/auth/send-sms-code`, body);
return data;
}
/**
* 校验短信验证码(可选前置校验,一般直接在业务接口 use
* POST /member/auth/validate-sms-code
*
* @param {string} mobile - 手机号(必填)
* @param {string} code - 验证码必填4-6 位数字)
* @param {number} scene - 短信场景(必填,见 SMS_SCENE
* @returns {Promise<Object>} 后端通用响应结构
*/
export async function validateSmsCode(mobile, code, scene) {
const { data } = await api.post(`${SERVER_BASE}/auth/validate-sms-code`, { mobile, code, scene });
return data;
}
/**
* 手机+验证码登录(首次即自动注册)
* POST /member/auth/sms-login
*
* @param {string} mobile - 手机号(必填)
* @param {string} code - 短信验证码(必填)
* @returns {Promise<Object>} data.data: { accessToken, refreshToken, expiresTime, userInfo }
*/
export async function loginBySms(mobile, code) {
const { data } = await api.post(`${SERVER_BASE}/auth/sms-login`, { mobile, code });
const info = data || {};
saveTokens(info);
return info;
}
/**
* 刷新令牌
* POST /member/auth/refresh-token?refreshToken=xxx
*
* @returns {Promise<Object>} data.data: { accessToken, refreshToken, expiresTime, userInfo }
*/
export async function refreshToken() {
const rt = getRefreshToken();
if (!rt) throw new Error('缺少 refresh_token');
// refreshToken 作为 URL 查询参数传递
const { data } = await api.post(`${SERVER_BASE}/auth/refresh-token`, null, { params: { refreshToken: rt } });
const info = data || {};
saveTokens(info);
return info;
}
/**
* 登录态下:发送“修改密码”验证码
* - 场景SMS_SCENE.MEMBER_UPDATE_PASSWORD
* - 特性:后端会以当前登录用户的手机号为准,无需传 mobile
* POST /member/auth/send-sms-code
*
* @returns {Promise<Object>} 后端通用响应结构
*/
export async function sendUpdatePasswordCode() {
const { data } = await api.post(`${SERVER_BASE}/auth/send-sms-code`, {
scene: SMS_SCENE.MEMBER_UPDATE_PASSWORD,
});
return data;
}
/**
* 登录态下:通过短信验证码修改密码
* PUT /member/user/update-password
*
* @param {string} newPassword - 新密码必填4-16 位)
* @param {string} smsCode - 短信验证码必填4-6 位数字)
* @returns {Promise<Object>} 后端通用响应结构
*/
export async function updatePasswordBySmsCode(newPassword, smsCode) {
const { data } = await api.put(`${SERVER_BASE}/user/update-password`, {
password: newPassword,
code: smsCode,
});
return data;
}
/**
* 未登录:发送“忘记密码”验证码
* - 场景SMS_SCENE.MEMBER_RESET_PASSWORD
* POST /member/auth/send-sms-code
*
* @param {string} mobile - 手机号(必填)
* @returns {Promise<Object>} 后端通用响应结构
*/
export async function sendResetPasswordCode(mobile) {
const { data } = await api.post(`${SERVER_BASE}/auth/send-sms-code`, {
mobile,
scene: SMS_SCENE.MEMBER_RESET_PASSWORD,
});
return data;
}
/**
* 未登录:通过手机验证码重置密码(忘记密码)
* PUT /member/user/reset-password
*
* @param {string} mobile - 手机号(必填)
* @param {string} newPassword - 新密码必填4-16 位)
* @param {string} smsCode - 短信验证码必填4-6 位数字)
* @returns {Promise<Object>} 后端通用响应结构
*/
export async function resetPasswordBySms(mobile, newPassword, smsCode) {
const { data } = await api.put(`${SERVER_BASE}/user/reset-password`, {
mobile,
password: newPassword,
code: smsCode,
});
return data;
}
/**
* “手机+验证码+密码注册”组合流程(基于短信登录即注册 + 设置密码)
* 说明:
* - 1) 发送登录场景验证码(可选:若已拿到 code 可跳过)
* - 2) 短信登录:首次会自动注册并返回 token
* - 3) 登录态下发送“修改密码”验证码
* - 4) 用短信验证码设置密码
*
* @param {string} mobile - 手机号(必填)
* @param {string} loginCode - 第一次登录使用的短信验证码(必填)
* @param {string} newPassword - 注册后设置的新密码(必填)
* @param {string} updatePwdCode - 设置密码用到的短信验证码(必填)
* @returns {Promise<void>}
*/
export async function registerWithMobileCodePassword(mobile, loginCode, newPassword, updatePwdCode) {
// 1) 可选:发送登录场景验证码(若已拿到 loginCode 可跳过)
// await sendSmsCode(mobile, SMS_SCENE.MEMBER_LOGIN);
// 2) 短信登录(首次即注册)
await loginBySms(mobile, loginCode);
// 3) 登录态下发送“修改密码”验证码(短信会发到当前账号绑定的手机号)
// await sendUpdatePasswordCode();
// 4) 用短信验证码设置密码
await updatePasswordBySmsCode(newPassword, updatePwdCode);
}
/**
* 导出一个默认对象,便于统一引入
*/
export default {
SMS_SCENE,
SMS_TEMPLATE_CODE,
loginByPassword,
sendSmsCode,
validateSmsCode,
loginBySms,
refreshToken,
sendUpdatePasswordCode,
updatePasswordBySmsCode,
sendResetPasswordCode,
resetPasswordBySms,
registerWithMobileCodePassword,
};

View File

@@ -0,0 +1,74 @@
import request from '@/api/http'
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { getAccessToken } from '@/utils/auth'
const SERVER_BASE_AI= import.meta.env.VITE_BASE_URL + '/admin-api/ai'
// AI chat 聊天
export const ChatMessageApi = {
// 创建【我的】聊天对话
createChatConversationMy: async (data) => {
return await request.post(`${SERVER_BASE_AI}/chat/conversation/create-my`, data)
},
// 发送 Stream 消息(对象入参,便于维护)
// 为什么不用 axios 呢?因为它不支持 SSE 调用
sendChatMessageStream: async (options) => {
const {
conversationId,
content,
ctrl,
enableContext = true,
enableWebSearch = false,
onMessage,
onError,
onClose,
attachmentUrls = []
} = options || {}
const token = getAccessToken()
let retryCount = 0
const maxRetries = 0 // 禁用自动重试
return fetchEventSource(`${SERVER_BASE_AI}/chat/message/send-stream`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
openWhenHidden: true,
body: JSON.stringify({
conversationId,
content,
useContext: enableContext,
useSearch: enableWebSearch,
attachmentUrls: attachmentUrls || []
}),
onmessage: onMessage,
onerror: (err) => {
retryCount++
console.error('SSE错误重试次数:', retryCount, err)
// 调用自定义错误处理
if (typeof onError === 'function') {
onError(err)
}
// 超过最大重试次数,停止重连
if (retryCount > maxRetries) {
throw err // 抛出错误,停止自动重连
}
},
onclose: () => {
// 调用自定义关闭处理
if (typeof onClose === 'function') {
onClose()
}
},
signal: ctrl ? ctrl.signal : undefined
})
},
}

View File

@@ -0,0 +1,67 @@
import http from '@/api/http'
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { getAuthHeader } from '@/utils/token-manager'
// 使用本地代理前缀 /tikhub开发环境通过 Vite 代理到 https://api.tikhub.io
const SERVER_BASE = '/webApi/admin-api/ai/tikHup'
export const CommonService = {
videoToCharacters(data) {
return http.post(`${SERVER_BASE}/videoToCharacters2`, data)
},
callWorkflow(data) {
return http.post(`${SERVER_BASE}/callWorkflow`, data)
},
// 流式调用 workflow
callWorkflowStream: async (options) => {
const {
data,
ctrl,
onMessage,
onError,
onClose
} = options || {}
const authHeader = getAuthHeader()
let retryCount = 0
const maxRetries = 0 // 禁用自动重试
return fetchEventSource(`${SERVER_BASE}/callWorkflow`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
...(authHeader ? { Authorization: authHeader } : {})
},
openWhenHidden: true,
body: JSON.stringify(data),
onmessage: onMessage,
onerror: (err) => {
retryCount++
console.error('SSE错误重试次数:', retryCount, err)
// 调用自定义错误处理
if (typeof onError === 'function') {
onError(err)
}
// 超过最大重试次数,停止重连
if (retryCount > maxRetries) {
throw err // 抛出错误,停止自动重连
}
},
onclose: () => {
// 调用自定义关闭处理
if (typeof onClose === 'function') {
onClose()
}
},
signal: ctrl ? ctrl.signal : undefined
})
}
}
export default CommonService

View File

@@ -0,0 +1,80 @@
import axios from 'axios'
import { getAuthHeader } from '@/utils/token-manager'
/**
* 不需要 token 的接口白名单
* 支持完整路径匹配或路径包含匹配
*/
const WHITE_LIST = [
'/auth/login', // 密码登录
'/auth/send-sms-code', // 发送验证码
'/auth/sms-login', // 短信登录
'/auth/validate-sms-code', // 验证验证码
'/auth/register', // 注册(如果有)
'/auth/reset-password', // 重置密码
'/auth/refresh-token', // 刷新token可选根据后端要求
]
/**
* 检查 URL 是否在白名单中
* @param {string} url - 请求 URL
* @returns {boolean}
*/
function isInWhiteList(url) {
if (!url) return false
return WHITE_LIST.some((path) => url.includes(path))
}
/**
* 可选:多租户场景可在此处统一注入 tenant-id
* api.interceptors.request.use((config) => {
* config.headers['tenant-id'] = '1';
* return config;
* });
*/
// 创建 axios 实例
const http = axios.create({
baseURL: '/',
timeout: 180000, // 3分钟
})
// 请求拦截:自动注入 Token
http.interceptors.request.use((config) => {
// 检查是否需要 token不在白名单中且未显式设置 isToken = false
const needToken = config.headers?.isToken !== false && !isInWhiteList(config.url || '')
if (needToken) {
// 使用统一的 token 管理器获取 header
const authHeader = getAuthHeader()
if (authHeader) {
config.headers.Authorization = authHeader
}
}
// 允许跨域第三方域名,使用绝对地址时保持原样
return config
})
http.interceptors.response.use(
(resp) => {
const data = resp.data
// 检查响应数据中的 code 字段
if (data && typeof data.code === 'number' && (data.code === 0 || data.code === 200)) {
return data
} else {
// code 不为 0 时,抛出错误
const error = new Error(data?.message || data?.msg || '请求失败')
error.code = data?.code
error.data = data
return Promise.reject(error)
}
},
(error) => {
// 统一错误处理:输出关键信息,便于排查 403 等问题
return Promise.reject(error)
}
)
export default http

View File

@@ -0,0 +1,5 @@
// TikHub API 统一导出入口
export { TikhubService } from './tikhub/tikhub.js'
export { default } from './tikhub/tikhub.js'
export { InterfaceType, MethodType, InterfaceUrlMap, ParamType } from './tikhub/types.js'

View File

@@ -0,0 +1,180 @@
# TikHub API 模块
本模块提供了统一的 TikHub 接口调用中间层,支持多种平台的 API 接口。
## 目录结构
```
tikhub/
├── types.js # 枚举定义InterfaceType、MethodType
├── tikhub.js # 核心服务类 TikhubService
├── index.js # 统一导出入口
├── example.js # 使用示例
└── README.md # 本文档
```
## 快速开始
### 1. 导入模块
```javascript
import TikhubService, { InterfaceType, MethodType } from '@/api/tikhub'
```
### 2. 调用接口
```javascript
// 调用抖音热门搜索接口
const response = await TikhubService.postTikHup(
InterfaceType.DOUYIN_WEB_HOT_SEARCH, // 接口类型
MethodType.GET, // HTTP 方法
{ keyword: '测试关键词' } // 实际接口参数
)
```
## API 说明
### TikhubService.postTikHup(type, methodType, urlParams)
统一调用 TikHub 接口的中间层方法。
**参数:**
- `type` (String) - 接口类型,使用 `InterfaceType` 枚举值
- `methodType` (String) - HTTP 方法类型,使用 `MethodType` 枚举值
- `urlParams` (Object|String) - 实际接口的参数
**返回:**
- Promise - axios 响应对象
**示例:**
```javascript
// 获取小红书热门榜单
await TikhubService.postTikHup(
InterfaceType.XIAOHONGSHU_WEB_HOT_LIST,
MethodType.GET,
{ page: 1, page_size: 20 }
)
// 搜索抖音内容
await TikhubService.postTikHup(
InterfaceType.DOUYIN_GENERAL_SEARCH_V4,
MethodType.POST,
{
keyword: '热门内容',
sort_type: 0,
publish_time: 0,
}
)
```
## 枚举说明
### InterfaceType - 接口类型
支持的所有接口类型:
| 枚举值 | 说明 |
|--------|------|
| `XIAOHONGSHU_USER_INFO` | 小红书 - 获取用户信息 |
| `DOUYIN_WEB_USER_POST_VIDEOS` | 抖音 - 网页端获取用户发布的视频 |
| `DOUYIN_APP_USER_POST_VIDEOS` | 抖音 - APP端V3获取用户发布的视频 |
| `DOUYIN_APP_GENERAL_SEARCH` | 抖音 - APP端V3通用搜索结果 |
| `DOUYIN_SEARCH_SUGGEST` | 抖音 - 搜索建议 |
| `DOUYIN_GENERAL_SEARCH_V4` | 抖音 - 通用搜索V4 |
| `XIAOHONGSHU_WEB_HOT_LIST` | 小红书 - 网页端V2热门榜单 |
| `DOUYIN_WEB_HOT_SEARCH` | 抖音 - 网页端热门搜索结果 |
| `KUAISHOU_WEB_HOT_LIST` | 快手 - 网页端热门榜单V2 |
| `BILIBILI_WEB_POPULAR` | B站 - 网页端流行内容 |
| `WEIBO_WEB_HOT_SEARCH` | 微博 - 网页端V2热门搜索指数 |
| `DOUYIN_WEB_GENERAL_SEARCH` | 抖音 - 网页端通用搜索结果 |
### MethodType - HTTP 方法
| 枚举值 | 说明 |
|--------|------|
| `GET` | GET 请求 |
| `POST` | POST 请求 |
| `PUT` | PUT 请求 |
| `DELETE` | DELETE 请求 |
| `PATCH` | PATCH 请求 |
## 使用示例
详细的使用示例请参考 `example.js` 文件。
### 示例 1获取抖音热门搜索
```javascript
import TikhubService, { InterfaceType, MethodType } from '@/api/tikhub'
async function getDouyinHotSearch() {
try {
const response = await TikhubService.postTikHup(
InterfaceType.DOUYIN_WEB_HOT_SEARCH,
MethodType.GET,
{ keyword: '测试关键词' }
)
return response
} catch (error) {
console.error('调用失败:', error)
throw error
}
}
```
### 示例 2获取用户发布的视频
```javascript
async function getUserVideos() {
return await TikhubService.postTikHup(
InterfaceType.DOUYIN_WEB_USER_POST_VIDEOS,
MethodType.GET,
{
sec_user_id: 'MS4wLjABAAAxxxxxxxx',
count: 20,
max_cursor: 0,
}
)
}
```
## 错误处理
```javascript
try {
const response = await TikhubService.postTikHup(
InterfaceType.DOUYIN_WEB_HOT_SEARCH,
MethodType.GET,
{ keyword: '测试' }
)
console.log('成功:', response)
} catch (error) {
if (error.message.includes('无效的接口类型')) {
console.error('接口类型错误')
} else {
console.error('请求失败:', error.message)
}
}
```
## 后端接口格式
本模块会调用后端接口 `/webApi/admin-api/ai/tikHup/post_tik_hup`,传递的参数格式为:
```json
{
"interface_type": "8",
"method_type": "GET",
"platform_url": "https://api.tikhub.io/api/v1/douyin/web/fetch_hot_search_result",
"url_params": { "keyword": "测试" }
}
```
## 注意事项
1. 所有接口类型必须使用 `InterfaceType` 枚举,不能使用字符串数字
2. HTTP 方法必须使用 `MethodType` 枚举
3. `urlParams` 可以是对象或字符串,取决于实际接口的要求
4. 后端会根据 `interface_type` 从数据库中获取对应的 `platform_token`

View File

@@ -0,0 +1,7 @@
/**
* TikHub API 统一导出
*/
export { TikhubService } from './tikhub.js'
export { default } from './tikhub.js'
export { InterfaceType, MethodType, InterfaceUrlMap, ParamType } from './types.js'

View File

@@ -0,0 +1,55 @@
import http from '@/api/http'
import { InterfaceType, MethodType, InterfaceUrlMap, ParamType } from './types'
import qs from 'qs'
// 使用本地代理前缀 /tikhub开发环境通过 Vite 代理到 https://api.tikhub.io
const SERVER_TIKHUB = '/webApi/admin-api/ai/tikHup'
/**
* TikHub API 服务类
*/
export const TikhubService = {
/**
* 统一调用 TikHub 接口的中间层方法
* @param {String} type - 接口类型,使用 InterfaceType 枚举
* @param {String} methodType - HTTP 方法类型,使用 MethodType 枚举
* @param {Object|String} urlParams - 实际接口的参数
* @returns {Promise} axios 响应
*
* @example
* // 调用抖音热门搜索接口
* TikhubService.postTikHup(
* InterfaceType.DOUYIN_WEB_HOT_SEARCH,
* MethodType.GET,
* { keyword: '测试' }
* )
*/
postTikHup({
type,
methodType,
urlParams,
paramType = '',
}) {
// 验证接口类型是否存在
if (!InterfaceUrlMap[type]) {
throw new Error(`无效的接口类型: ${type}`)
}
// 构造请求参数
const requestData = {
type: type,
methodType: methodType,
paramType,
urlParams: paramType === ParamType.JSON ? JSON.stringify(urlParams) : qs.stringify(urlParams),
}
return http.post(`${SERVER_TIKHUB}/postTikHup`, qs.stringify(requestData))
},
}
export default TikhubService
// 导出枚举,方便外部使用
export { InterfaceType, MethodType, InterfaceUrlMap }

View File

@@ -0,0 +1,87 @@
/**
* TikHub 接口类型枚举
* 对应数据库 tik_token 表中的 interface_type 字段
*/
export const InterfaceType = {
/** 小红书 - 获取用户信息 */
XIAOHONGSHU_USER_INFO: '1',
/** 抖音 - 网页端获取用户发布的视频 */
DOUYIN_WEB_USER_POST_VIDEOS: '2',
/** 抖音 - APP端V3获取用户发布的视频 */
DOUYIN_APP_USER_POST_VIDEOS: '3',
/** 抖音 - APP端V3通用搜索结果 */
DOUYIN_APP_GENERAL_SEARCH: '4',
/** 抖音 - 搜索建议 */
DOUYIN_SEARCH_SUGGEST: '5',
/** 抖音 - 通用搜索V4 */
DOUYIN_GENERAL_SEARCH_V4: '6',
/** 小红书 - 网页端V2热门榜单 */
XIAOHONGSHU_WEB_HOT_LIST: '7',
/** 抖音 - 网页端热门搜索结果 */
DOUYIN_WEB_HOT_SEARCH: '8',
/** 快手 - 网页端热门榜单V2 */
KUAISHOU_WEB_HOT_LIST: '9',
/** B站 - 网页端流行内容 */
BILIBILI_WEB_POPULAR: '10',
/** 微博 - 网页端V2热门搜索指数 */
WEIBO_WEB_HOT_SEARCH: '11',
/** 抖音 - 网页端通用搜索结果 */
DOUYIN_WEB_GENERAL_SEARCH: '12',
/** 抖音 - APP通用搜索结果 */
DOUYIN_SEARCH_GENERAL_SEARCH: '14',
}
/**
* HTTP 请求方法枚举
*/
export const MethodType = {
GET: 'GET',
POST: 'POST',
PUT: 'PUT',
DELETE: 'DELETE',
PATCH: 'PATCH',
}
/**
* 请求参数类型枚举
*/
export const ParamType = {
JSON: 'json',
FORM: 'form'
}
/**
* 接口类型与平台 URL 的映射
* 根据 tik_token 表中的数据生成
*/
export const InterfaceUrlMap = {
[InterfaceType.XIAOHONGSHU_USER_INFO]: 'https://api.tikhub.io/api/v1/xiaohongshu/app/get_user_info',
[InterfaceType.DOUYIN_WEB_USER_POST_VIDEOS]: 'https://api.tikhub.io/api/v1/douyin/web/fetch_user_post_videos',
[InterfaceType.DOUYIN_APP_USER_POST_VIDEOS]: 'https://api.tikhub.io/api/v1/douyin/app/v3/fetch_user_post_videos',
[InterfaceType.DOUYIN_APP_GENERAL_SEARCH]: 'https://api.tikhub.io/api/v1/douyin/app/v3/fetch_general_search_result',
[InterfaceType.DOUYIN_SEARCH_GENERAL_SEARCH]: 'https://api.tikhub.io/api/v1/douyin/search/fetch_general_search_v4',
[InterfaceType.DOUYIN_SEARCH_SUGGEST]: 'https://api.tikhub.io/api/v1/douyin/search/fetch_search_suggest',
[InterfaceType.DOUYIN_GENERAL_SEARCH_V4]: 'https://api.tikhub.io/api/v1/douyin/search/fetch_general_search_v4',
[InterfaceType.XIAOHONGSHU_WEB_HOT_LIST]: 'https://api.tikhub.io/api/v1/xiaohongshu/web_v2/fetch_hot_list',
[InterfaceType.DOUYIN_WEB_HOT_SEARCH]: 'https://api.tikhub.io/api/v1/douyin/web/fetch_hot_search_result',
[InterfaceType.KUAISHOU_WEB_HOT_LIST]: 'https://api.tikhub.io/api/v1/kuaishou/web/fetch_kuaishou_hot_list_v2',
[InterfaceType.BILIBILI_WEB_POPULAR]: 'https://api.tikhub.io/api/v1/bilibili/web/fetch_com_popular',
[InterfaceType.WEIBO_WEB_HOT_SEARCH]: 'https://api.tikhub.io/api/v1/weibo/web_v2/fetch_hot_search_index',
[InterfaceType.DOUYIN_WEB_GENERAL_SEARCH]: 'https://api.tikhub.io/api/v1/douyin/web/fetch_general_search_result',
}
export default {
InterfaceType,
MethodType,
InterfaceUrlMap,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,123 @@
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
// 单色 SVG 图标(填充 currentColor可继承文本色
const icons = {
home: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M3 10.5 12 3l9 7.5"/><path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7"/></svg>',
grid: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>',
text: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M4 7h16"/><path d="M4 12h10"/><path d="M4 17h14"/></svg>',
mic: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><path d="M12 19v3"/></svg>',
wave: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M2 12s2-4 5-4 3 8 6 8 3-8 6-8 3 4 3 4"/></svg>',
user: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><circle cx="12" cy="7" r="4"/><path d="M5.5 21a8.38 8.38 0 0 1 13 0"/></svg>'
}
const items = computed(() => {
// 小标题(功能) + 模块(子菜单)的形式;使用单色 SVG 图标
return [
{
title: '功能',
children: [
// { path: '/home', label: '首页', icon: 'home' },
{ path: '/content-style/benchmark', label: '对标分析', icon: 'grid' },
{ path: '/content-style/copywriting', label: '文案创作', icon: 'text' },
{ path: '/trends/forecast', label: '热点趋势', icon: 'text' },
]
},
{
title: '配音',
children: [
{ path: '/digital-human/voice-copy', label: '人声克隆', icon: 'mic' },
{ path: '/digital-human/voice-generate', label: '生成配音', icon: 'wave' },
]
},
// {
// title: '视频',
// children: [
// { path: '/digital-human/avatar', label: '生成数字人', icon: 'user' },
// ]
// },
]
})
function go(p) {
router.push(p)
}
</script>
<template>
<aside class="sidebar">
<nav class="sidebar__nav">
<div v-for="group in items" :key="group.title" class="nav-group">
<div class="nav-group__title">{{ group.title }}</div>
<button v-for="it in group.children" :key="it.path" class="nav-item" :class="{ 'is-active': route.path === it.path }" @click="go(it.path)">
<span class="nav-item__icon" aria-hidden="true" v-html="icons[it.icon]"></span>
<span class="nav-item__label">{{ it.label }}</span>
</button>
</div>
</nav>
</aside>
</template>
<style scoped>
.sidebar {
position: sticky;
top: 70px; /* 与 TopNav 高度一致 */
height: calc(100vh - 70px);
width: 220px;
border-right: 1px solid var(--color-border);
background: var(--color-surface);
}
.sidebar__nav {
display: flex;
flex-direction: column;
padding: 12px;
gap: 6px;
}
.nav-group { display: flex; flex-direction: column; gap: 6px; }
.nav-group__title {
height: 30px;
display: flex;
align-items: center;
padding: 0 8px;
font-size: var(--font-small-size);
color: var(--color-text-secondary);
letter-spacing: .06em;
}
.nav-item {
height: 40px;
border-radius: var(--radius-card);
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
color: var(--color-text-secondary);
background: var(--color-surface);
border: 1px solid var(--color-border);
cursor: pointer;
transition: background .2s ease, color .2s ease, box-shadow .2s ease, transform .12s ease, border-color .2s ease;
}
.nav-item:hover {
background: #161616; /* hover态加深 */
color: var(--color-text);
}
.nav-item.is-active {
background: var(--color-primary);;
color: var(--color-text);
border-color: var(--color-primary);
}
.nav-item__icon { width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; }
.nav-item__label { font-size: var(--font-body-size); font-weight: 600; }
</style>

View File

@@ -0,0 +1,102 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import { message } from 'ant-design-vue'
import { setDevToken, getDevToken } from '@/utils/token-manager'
const token = ref('')
const isVisible = ref(true)
// 从 sessionStorage 恢复 token
onMounted(() => {
const saved = getDevToken()
if (saved) {
token.value = saved
}
})
// 保存 token
const handleSave = () => {
if (token.value.trim()) {
setDevToken(token.value)
message.success('Token 已保存')
} else {
setDevToken('')
message.success('Token 已清除')
}
}
// 监听输入变化,自动保存(防抖)
let saveTimer = null
watch(token, () => {
clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
if (token.value.trim()) {
setDevToken(token.value)
}
}, 500)
})
</script>
<template>
<div v-if="isVisible" class="token-input-wrapper">
<div class="token-input-label">Dev Token</div>
<input
v-model="token"
type="password"
placeholder="输入测试 token"
class="token-input"
@keyup.enter="handleSave"
/>
<button class="token-btn" @click="handleSave">保存</button>
</div>
</template>
<style scoped>
.token-input-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.token-input-label {
font-size: 12px;
color: var(--color-text-secondary);
white-space: nowrap;
}
.token-input {
width: 200px;
height: 28px;
padding: 0 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-surface);
color: var(--color-text);
font-size: 12px;
outline: none;
transition: all 0.2s;
}
.token-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
.token-btn {
height: 28px;
padding: 0 12px;
border: 1px solid var(--color-primary);
border-radius: 4px;
background: transparent;
color: var(--color-primary);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.token-btn:hover {
background: var(--color-primary);
color: #fff;
}
</style>

View File

@@ -0,0 +1,106 @@
<script setup>
import { ref } from 'vue'
import { useUserStore } from '@/stores/user'
import LoginModal from '@/components/LoginModal.vue'
import TokenInput from '@/components/TokenInput.vue'
const styles = {
background: 'var(--color-surface)',
color: 'var(--color-text)'
}
// const route = useRoute()
const userStore = useUserStore()
const showLogin = ref(false)
// function go(path) {
// router.push(path)
// }
</script>
<template>
<header
class="header-box"
:style="styles"
>
<div>
<div class="h-[70px] flex items-center">
<div class="flex items-center gap-3 flex-1 pl-[30px]">
<!-- 左侧可放 logo 或其他内容 -->
</div>
<div class="flex items-center gap-4 pr-[35px]">
<!-- Token 输入仅开发/测试环境 -->
<TokenInput />
<template v-if="userStore.isLoggedIn && userStore.displayAvatar">
<img class="w-8 h-8 rounded-full" :src="userStore.displayAvatar" alt="avatar" />
</template>
<template v-else>
<button class="btn-primary-nav" @click="showLogin = true">免费试用</button>
</template>
</div>
</div>
</div>
</header>
<LoginModal v-model:open="showLogin" />
</template>
<style scoped>
.header-box {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
border-bottom: 1px solid var(--color-border);
}
.nav-link {
height: 70px;
display: inline-flex;
align-items: center;
cursor: pointer;
padding: 0 6px;
color: var(--color-text-secondary);
position: relative;
transition: color .2s ease;
}
.nav-link:hover {
color: var(--color-text);
}
.nav-link--active {
color: var(--color-text);
font-weight: 600;
}
.nav-link--active::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: 10px;
height: 2px;
background: linear-gradient(90deg, var(--color-primary), var(--color-blue));
opacity: 0.55;
border-radius: 2px;
}
.btn-primary-nav {
height: 32px;
padding: 0 12px;
border-radius: 8px;
background: var(--color-primary);
color: #fff;
font-size: 12px;
font-weight: 600;
box-shadow: var(--glow-primary);
transition: transform .2s ease, box-shadow .2s ease, filter .2s ease;
}
.btn-primary-nav:hover {
transform: translateY(-1px);
box-shadow: var(--glow-primary);
filter: brightness(1.03);
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<svg :class="['gm-icon', cls]" :width="size" :height="size" aria-hidden="true">
<use :href="`#${name}`" />
</svg>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
name: { type: String, required: true },
size: { type: [Number, String], default: 16 },
class: { type: String, default: '' },
})
const cls = computed(() => props.class)
defineOptions({ name: 'GmIcon' })
</script>
<style scoped>
.gm-icon {
display: inline-block;
vertical-align: middle;
color: currentColor;
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true" focusable="false">
<symbol id="icon-topic" viewBox="0 0 24 24">
<path d="M5 12a7 7 0 1 1 6.8 7.0l-3.3 2.0.8-3.2A6.9 6.9 0 0 1 5 12Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="8" cy="18" r="1" fill="currentColor"/>
<circle cx="6" cy="16" r="1" fill="currentColor"/>
</symbol>
<symbol id="icon-paragraph" viewBox="0 0 24 24">
<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v16.5H7a3 3 0 0 0-3 3V5.5Z" fill="none" stroke="currentColor" stroke-width="1.6"/>
<path d="M7 3v16.5" fill="none" stroke="currentColor" stroke-width="1.6"/>
</symbol>
<symbol id="icon-video" viewBox="0 0 24 24">
<rect x="3" y="5" width="14" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
<path d="M17 9l4-2v10l-4-2v-6Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
<path d="M3 9h14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-dasharray="2 2"/>
</symbol>
<symbol id="icon-sparkle" viewBox="0 0 24 24">
<path d="M12 3l1.2 3.5L17 8l-3.8 1.2L12 13l-1.2-3.8L7 8l3.8-1.5L12 3Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</symbol>
<symbol id="icon-spinner" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2" opacity="0.2"/>
<path d="M21 12a9 9 0 0 0-9-9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</symbol>
<symbol id="icon-eye" viewBox="0 0 24 24">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" fill="none" stroke="currentColor" stroke-width="1.6"/>
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.6"/>
</symbol>
<symbol id="icon-edit" viewBox="0 0 24 24">
<path d="M4 20l4.5-1 10-10a1.5 1.5 0 0 0-2.1-2.1L6.5 16.9 5 21 4 20Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</symbol>
<symbol id="icon-copy" viewBox="0 0 24 24">
<rect x="9" y="7" width="11" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
<rect x="4" y="4" width="11" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
</symbol>
<symbol id="icon-save" viewBox="0 0 24 24">
<path d="M4 4h12l4 4v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z" fill="none" stroke="currentColor" stroke-width="1.6"/>
<path d="M7 4v6h10V6l-2-2H7Z" fill="none" stroke="currentColor" stroke-width="1.6"/>
</symbol>
<symbol id="icon-cancel" viewBox="0 0 24 24">
<path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</symbol>
<symbol id="icon-empty-doc" viewBox="0 0 24 24">
<rect x="4" y="3" width="16" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
<path d="M8 8h8M8 12h8M8 16h5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</symbol>
</svg>
</template>
<script setup>
// 无逻辑,仅提供一次性注入的 symbol sprite
</script>
<style scoped>
/* 隐藏容器但保留 DOM */
</style>

29
frontend/app/web-gold/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_DEV_TOKEN: string
readonly VITE_TENANT_ID: string
readonly VITE_PROXY_TARGET: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
// 声明 ant-design-vue 的类型
declare module 'ant-design-vue' {
export const message: {
success: (content: string) => void
error: (content: string) => void
warning: (content: string) => void
info: (content: string) => void
}
export const Modal: any
}

View File

@@ -0,0 +1,19 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Antd from 'ant-design-vue'
import 'normalize.css'
import 'ant-design-vue/dist/reset.css'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.use(router)
app.use(Antd)
app.mount('#app')

View File

@@ -0,0 +1,51 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
redirect: '/content-style/benchmark'
},
// { path: '/home', name: '首页', component: () => import('../views/home/Home.vue') },
{
path: '/content-style',
name: '内容风格分析',
children: [
{ path: '', redirect: '/content-style/benchmark' },
{ path: 'benchmark', name: '对标分析', component: () => import('../views/content-style/Benchmark.vue') },
{ path: 'copywriting', name: '文案创作', component: () => import('../views/content-style/Copywriting.vue') },
]
},
{
path: '/trends',
name: '热点趋势分析',
children: [
{ path: '', redirect: '/trends/heat' },
{ path: 'heat', name: '热度分析', component: () => import('../views/trends/Heat.vue') },
{ path: 'forecast', name: '热点预测', component: () => import('../views/trends/Forecast.vue') },
{ path: 'copywriting', name: '趋势文案创作', component: () => import('../views/trends/Copywriting.vue') },
]
},
{
path: '/digital-human',
name: '数字人',
children: [
{ path: '', redirect: '/digital-human/voice-copy' },
{ path: 'voice-copy', name: '人声克隆', component: () => import('../views/dh/VoiceCopy.vue') },
{ path: 'voice-generate', name: '生成配音', component: () => import('../views/dh/VoiceGenerate.vue') },
{ path: 'avatar', name: '生成数字人', component: () => import('../views/dh/Avatar.vue') },
]
},
{ path: '/realtime-hot', name: '实时热点推送', component: () => import('../views/realtime/RealtimeHot.vue') },
{ path: '/mix-editor', name: '素材混剪', component: () => import('../views/mix/MixEditor.vue') },
{ path: '/capcut-import', name: '剪映导入', component: () => import('../views/capcut/CapcutImport.vue') },
{ path: '/help', name: '帮助', component: () => import('../views/misc/Help.vue') },
{ path: '/download', name: '下载', component: () => import('../views/misc/Download.vue') },
{ path: '/settings/theme', name: '主题设置', component: () => import('../views/misc/Theme.vue') },
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
export default router

View File

@@ -0,0 +1,40 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import localforage from 'localforage'
export const usePromptStore = defineStore('prompt', () => {
// 存储当前选中的提示词
const currentPrompt = ref('')
// 存储提示词相关的视频信息
const currentVideoInfo = ref(null)
// 设置提示词
function setPrompt(prompt, videoInfo = null) {
currentPrompt.value = prompt
currentVideoInfo.value = videoInfo
}
// 清空提示词
function clearPrompt() {
currentPrompt.value = ''
currentVideoInfo.value = null
}
return {
currentPrompt,
currentVideoInfo,
setPrompt,
clearPrompt
}
}, {
persist: {
key: 'prompt-store',
storage: {
getItem: (key) => localforage.getItem(key),
setItem: (key, value) => localforage.setItem(key, value),
removeItem: (key) => localforage.removeItem(key)
},
paths: ['currentPrompt', 'currentVideoInfo']
}
})

View File

@@ -0,0 +1,152 @@
import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
import { getJSON, setJSON, remove } from '@/utils/storage'
// 本地持久化的 key
const STORAGE_KEY = 'user_store_v1'
export const useUserStore = defineStore('user', () => {
// 基本信息
const isLoggedIn = ref(false)
const userId = ref('')
const nickname = ref('')
const avatar = ref('')
// 微信相关
const wechatOpenId = ref('')
const wechatUnionId = ref('')
const wechatNickname = ref('')
const wechatAvatar = ref('')
// 资产与配额
const balance = ref(0)
const vipLevel = ref(0)
const credits = ref(0)
const displayName = computed(() => nickname.value || wechatNickname.value || '未命名用户')
const displayAvatar = computed(() => avatar.value || wechatAvatar.value || '')
// 恢复本地
async function hydrateFromStorage() {
const saved = await getJSON(STORAGE_KEY)
if (!saved) return
isLoggedIn.value = !!saved.isLoggedIn
userId.value = saved.userId || ''
nickname.value = saved.nickname || ''
avatar.value = saved.avatar || ''
wechatOpenId.value = saved.wechatOpenId || ''
wechatUnionId.value = saved.wechatUnionId || ''
wechatNickname.value = saved.wechatNickname || ''
wechatAvatar.value = saved.wechatAvatar || ''
balance.value = Number(saved.balance || 0)
vipLevel.value = Number(saved.vipLevel || 0)
credits.value = Number(saved.credits || 0)
}
// 持久化
async function persist() {
const payload = {
isLoggedIn: isLoggedIn.value,
userId: userId.value,
nickname: nickname.value,
avatar: avatar.value,
wechatOpenId: wechatOpenId.value,
wechatUnionId: wechatUnionId.value,
wechatNickname: wechatNickname.value,
wechatAvatar: wechatAvatar.value,
balance: balance.value,
vipLevel: vipLevel.value,
credits: credits.value,
}
await setJSON(STORAGE_KEY, payload)
}
// 监听关键字段做持久化
;[
isLoggedIn,
userId,
nickname,
avatar,
wechatOpenId,
wechatUnionId,
wechatNickname,
wechatAvatar,
balance,
vipLevel,
credits,
].forEach((s) => watch(s, persist))
// 登录/登出动作(示例:具体接入后端可在此对接)
async function loginWithPhone(payload) {
// payload: { phone, code, profile? }
isLoggedIn.value = true
userId.value = payload?.profile?.userId || userId.value
nickname.value = payload?.profile?.nickname || nickname.value
avatar.value = payload?.profile?.avatar || avatar.value
balance.value = payload?.profile?.balance ?? balance.value
vipLevel.value = payload?.profile?.vipLevel ?? vipLevel.value
credits.value = payload?.profile?.credits ?? credits.value
await persist()
}
async function loginWithWeChat(profile) {
// profile: { openId, unionId, nickname, avatar, balance, vipLevel, credits }
isLoggedIn.value = true
wechatOpenId.value = profile?.openId || ''
wechatUnionId.value = profile?.unionId || ''
wechatNickname.value = profile?.nickname || ''
wechatAvatar.value = profile?.avatar || ''
balance.value = profile?.balance ?? balance.value
vipLevel.value = profile?.vipLevel ?? vipLevel.value
credits.value = profile?.credits ?? credits.value
await persist()
}
async function updateBalance(delta) {
balance.value = Math.max(0, Number(balance.value) + Number(delta || 0))
await persist()
}
async function logout() {
isLoggedIn.value = false
userId.value = ''
nickname.value = ''
avatar.value = ''
wechatOpenId.value = ''
wechatUnionId.value = ''
wechatNickname.value = ''
wechatAvatar.value = ''
balance.value = 0
vipLevel.value = 0
credits.value = 0
await remove(STORAGE_KEY)
}
// 初始化从本地恢复
hydrateFromStorage()
return {
// state
isLoggedIn,
userId,
nickname,
avatar,
wechatOpenId,
wechatUnionId,
wechatNickname,
wechatAvatar,
balance,
vipLevel,
credits,
// getters
displayName,
displayAvatar,
// actions
loginWithPhone,
loginWithWeChat,
updateBalance,
logout,
}
})
export default useUserStore

View File

@@ -0,0 +1,62 @@
import { defineStore } from 'pinia'
import storage from '@/utils/storage'
const STORAGE_KEY = 'cosy_voice_profiles'
export const useVoiceCopyStore = defineStore('voiceCopy', {
state: () => ({
profiles: [],
activeId: '',
loaded: false,
}),
getters: {
activeProfile(state) {
return state.profiles.find(p => p.id === state.activeId) || null
}
},
actions: {
generateId() {
return `${Date.now()}_${Math.floor(Math.random() * 1e6)}`
},
async load() {
if (this.loaded) return
const list = await storage.getJSON(STORAGE_KEY, [])
this.profiles = Array.isArray(list) ? list : []
if (!this.activeId && this.profiles.length) this.activeId = this.profiles[0].id
this.loaded = true
},
async persist() {
await storage.setJSON(STORAGE_KEY, this.profiles)
},
async add(profile) {
const id = this.generateId()
const name = profile.name || `克隆语音-${this.profiles.length + 1}`
const payload = { ...profile, id, name }
this.profiles.unshift(payload)
this.activeId = id
await this.persist()
return payload
},
async update(profile) {
const idx = this.profiles.findIndex(p => p.id === profile.id)
if (idx === -1) return await this.add({ ...profile, id: '' })
this.profiles[idx] = { ...profile }
await this.persist()
return this.profiles[idx]
},
async duplicate(profile, name) {
const copy = { ...profile, id: '', name }
return await this.add(copy)
},
async remove(id) {
this.profiles = this.profiles.filter(p => p.id !== id)
if (this.activeId === id) this.activeId = this.profiles[0]?.id || ''
await this.persist()
},
select(id) {
this.activeId = id
}
}
})

View File

@@ -0,0 +1,117 @@
@import "tailwindcss";
/* 简单的图标占位类 */
.i-bell::before { content: "🔔"; display: inline-block; }
/* 全局滚动条稳定,避免页面切换时左右抖动 */
body { scrollbar-gutter: stable both-edges; }
/* 统一阴影层级(与 antd 风格接近) */
.elev-1 { box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.elev-2 { box-shadow: 0 2px 8px rgba(0,0,0,0.10); }
/* 通用卡片表面(可在各页面复用) */
.card-surface {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
/* ==========================
设计规范:根变量与暗色主题
来源:.cursorules/design.md
========================== */
:root {
/* 颜色 - 主色与中性色 */
--color-bg: #0D0D0D; /* 背景:深黑 */
--color-surface: #1A1A1A; /* 模块底 */
--color-text: #F2F2F2; /* 正文文本 */
--color-text-secondary: #CCCCCC; /* 次要文本 */
--color-border: #333333; /* 边框 */
--color-primary: #00B030; /* 主功能色 */
--color-blue: #1A66E0; /* 辅助交互蓝 */
--color-accent: #FF6A30; /* 强调橙 */
/* 字号与行高 */
--font-title-size: 20px; /* Montserrat 半粗体 */
--font-body-size: 14px; /* Inter 常规 */
--font-small-size: 12px; /* 辅助文本 */
--line-height-base: 1.5;
/* 圆角与阴影 */
--radius-card: 6px; /* 卡片圆角 */
--shadow-inset-card: inset 0 2px 4px rgba(0,0,0,0.4);
--glow-primary: 0 0 6px rgba(0,176,48,0.3);
}
/* 正确的 :root 变量声明(专业科技蓝方案) */
:root {
/* 主色系 - 科技蓝 */
--color-primary: #3B82F6;
--color-primary-light: #60A5FA;
--color-primary-dark: #2563EB;
--color-primary-glow: rgba(59, 130, 246, 0.3);
/* 辅助色 */
--color-blue: #1A66E0;
--color-accent: #FF6A30;
/* 中性色(保持) */
--color-bg: #0D0D0D;
--color-surface: #1A1A1A;
--color-text: #F2F2F2;
--color-text-secondary: #CCCCCC;
--color-border: #333333;
/* 尺寸与阴影(保持) */
--radius-card: 6px;
--shadow-inset-card: inset 0 2px 4px rgba(0,0,0,0.4);
--glow-primary: 0 0 6px var(--color-primary-glow);
}
/* 全局暗色基础 */
html, body, #app {
background: var(--color-bg);
color: var(--color-text);
font-size: var(--font-body-size);
line-height: var(--line-height-base);
}
/* 卡片:遵循新规范(默认暗色表面) */
.card-surface--dark {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-inset-card);
}
/* 按钮主色(用于顶部试用按钮等) */
.btn-primary {
background: var(--color-primary);
color: #fff;
}
.btn-primary:hover {
box-shadow: var(--glow-primary);
filter: brightness(1.03);
}
/* 次要文本与分割线工具类 */
.text-secondary { color: var(--color-text-secondary); }
.border-base { border-color: var(--color-border); }
/* 覆盖 antd 组件的占位符与主按钮色(全局) */
:root :where(.ant-input, .ant-input-affix-wrapper, .ant-select-selector, textarea)::placeholder {
color: color-mix(in oklab, var(--color-text-secondary) 80%, transparent);
}
:root :where(.ant-btn-primary) {
background: var(--color-primary);
border-color: var(--color-primary);
}
:root :where(.ant-btn-primary:hover, .ant-btn-primary:focus) {
background: var(--color-primary);
border-color: var(--color-primary);
box-shadow: var(--glow-primary);
}

View File

@@ -0,0 +1,17 @@
import 'axios'
declare module 'axios' {
export interface AxiosResponse<T = any> {
code?: number
message?: string
data: T
[key: string]: any
}
export interface AxiosError<T = any> extends Error {
code?: number | string
data?: T
[key: string]: any
}
}

View File

@@ -0,0 +1,29 @@
// 全局类型声明文件
/**
* 音频项接口
*/
export interface AudioItem {
audio_url: string
}
/**
* 转录结果接口
*/
export interface TranscriptionResult {
key: string
audio_url?: string
value: string
}
/**
* 视频转字符响应接口
*/
export interface VideoToCharactersResponse {
data: {
results: Array<{
transcription_url: string
}>
}
}

View File

@@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -0,0 +1,42 @@
import { useCache } from '@gold/hooks/web/useCache'
import { getToken as getTokenFromManager } from './token-manager'
const { wsCache } = useCache()
const AccessTokenKey = 'ACCESS_TOKEN'
const RefreshTokenKey = 'REFRESH_TOKEN'
// 获取token - 使用统一的 token 管理器
export const getAccessToken = () => {
return getTokenFromManager()
}
// 刷新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)
}
// 删除token
export const removeToken = () => {
wsCache.delete(AccessTokenKey)
wsCache.delete(RefreshTokenKey)
}
/** 格式化tokenjwt格式 */
export const formatToken = (token) => {
return 'Bearer ' + token
}

View File

@@ -0,0 +1,20 @@
import MarkdownIt from 'markdown-it'
// 创建 markdown 实例
const md = new MarkdownIt()
/**
* 渲染 markdown 内容为 HTML
* @param {string} content - markdown 内容
* @returns {string} HTML 字符串
*/
export function renderMarkdown(content) {
if (!content) return ''
return md.render(content)
}
/**
* 导出 md 实例,供需要自定义配置的地方使用
*/
export { md }

View File

@@ -0,0 +1,56 @@
import localforage from 'localforage'
// 统一的 IndexedDB 存储封装API 与 localStorage 类似但为异步
// 默认使用一个命名的存储实例,便于后续扩展多命名空间
const store = localforage.createInstance({
name: 'web-gold',
storeName: 'app_store',
description: 'App persistent storage powered by IndexedDB',
})
export async function setJSON(key, value) {
try {
const json = JSON.stringify(value)
await store.setItem(key, json)
return true
} catch (err) {
console.error('[storage.setJSON] failed:', err)
return false
}
}
export async function getJSON(key, fallback = null) {
try {
const raw = await store.getItem(key)
if (raw == null) return fallback
return JSON.parse(raw)
} catch (err) {
console.error('[storage.getJSON] failed:', err)
return fallback
}
}
export async function remove(key) {
try {
await store.removeItem(key)
} catch (err) {
console.error('[storage.remove] failed:', err)
}
}
export async function clearAll() {
try {
await store.clear()
} catch (err) {
console.error('[storage.clearAll] failed:', err)
}
}
export default {
setJSON,
getJSON,
remove,
clearAll,
}

View File

@@ -0,0 +1,100 @@
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)
}
}

View File

@@ -0,0 +1,55 @@
import { match as pathMatch } from 'path-to-regexp'
/**
* 安全解析 URL失败返回 null
*/
export function safeParseUrl(input) {
const raw = (input || '').trim()
if (!raw) return null
try {
return new URL(raw)
} catch {
return null
}
}
/**
* 从 URL 对象按顺序读取第一个非空的 query 值
*/
export function getFirstQuery(urlObj, keys = []) {
if (!urlObj || !urlObj.searchParams) return ''
for (const key of keys) {
const v = urlObj.searchParams.get(key)
if (v) return v
}
return ''
}
// 组合解析:优先从 queryKeys 取值,其次按 path-to-regexp 的模式从 pathname 匹配(返回首个命名参数),最后可回退 raw
export function resolveId(input, options = {}) {
const raw = (input || '').trim()
if (!raw) return ''
const { queryKeys = [], pathPatterns = [], fallbackRaw = true } = options
const urlObj = safeParseUrl(raw)
const q = getFirstQuery(urlObj, queryKeys)
if (q) return q
if (urlObj) {
for (const pattern of pathPatterns) {
if (typeof pattern === 'string') {
const m = pathMatch(pattern)(urlObj.pathname)
if (m && m.params) {
const firstKey = Object.keys(m.params)[0]
const val = firstKey ? m.params[firstKey] : ''
if (val) return String(val)
}
} else if (pattern instanceof RegExp) {
const m = urlObj.pathname.match(pattern)
if (m && m[1]) return m[1]
}
}
}
return fallbackRaw ? raw : ''
}

View File

@@ -0,0 +1,15 @@
<script setup>
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">剪映导入</h2>
<div class="bg-white p-4 rounded shadow">选择文案/字幕/音频/数字人视频/工程一键导入</div>
</div>
</template>
<style scoped>
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,905 @@
<script setup>
import { ref, onMounted } from 'vue'
import { usePromptStore } from '@/stores/prompt'
import MarkdownIt from 'markdown-it'
import { message } from 'ant-design-vue'
import { CommonService } from '@/api/common'
import useVoiceText from '@gold/hooks/web/useVoiceText'
import GmIcon from '@/components/icons/Icon.vue'
const promptStore = usePromptStore()
const md = new MarkdownIt()
// 表单数据(合并为单一输入)
const form = ref({
prompt: '',
userInput: '', // 用户输入的文本或视频链接
amplitude: 50 // 幅度默认50%
})
// 生成的文案内容
const generatedContent = ref('')
// 编辑模式相关
const isEditMode = ref(false)
const editableContent = ref('')
const originalContent = ref('')
// 加载状态
const isLoading = ref(false)
const { getVoiceText } = useVoiceText()
// 页面加载时,如果有提示词则自动填充
onMounted(() => {
if (promptStore.currentPrompt) {
form.value.prompt = promptStore.currentPrompt
}
})
// 生成文案(流式)
async function generateCopywriting() {
const inputContent = form.value.userInput || ''
if (!inputContent.trim()) {
message.warning('请输入内容')
return
}
isLoading.value = true
generatedContent.value = '' // 清空之前的内容
try {
// 如果看起来是视频/音频链接,先尝试转写;否则直接作为文本
let userText = inputContent
if (isLikelyUrl(inputContent)) {
try {
message.info('正在获取视频转写...')
const transcriptions = await getVoiceText([{ audio_url: inputContent }])
const transcript = Array.isArray(transcriptions) && transcriptions[0] ? (transcriptions[0].value || '') : ''
if (transcript.trim()) {
userText = transcript
} else {
message.warning('未从链接获取到可用的语音文本,将直接使用原始输入')
}
} catch (e) {
console.warn('获取转写失败,使用原始输入:', e)
}
}
// 调用 callWorkflow 流式 API
const requestData = {
audio_prompt: form.value.prompt || '', // 音频提示词
user_text: userText, // 用户输入内容或由链接转写得到的文本
amplitude: form.value.amplitude // 幅度,范围 0-100
}
const ctrl = new AbortController()
let fullText = ''
let errorOccurred = false
let isResolved = false
await new Promise((resolve, reject) => {
// 设置超时
const timeout = setTimeout(() => {
if (!isResolved) {
ctrl.abort()
reject(new Error('请求超时,请稍后重试'))
}
}, 180000) // 3分钟超时
CommonService.callWorkflowStream({
data: requestData,
ctrl,
onMessage: (event) => {
try {
if (errorOccurred) return
const dataStr = event?.data || ''
if (!dataStr) return
try {
const obj = JSON.parse(dataStr)
// 根据实际返回格式解析
const piece = obj?.text || obj?.content || obj?.data || ''
if (piece) {
fullText += piece
generatedContent.value = fullText
}
} catch (parseErr) {
console.warn('解析流数据异常:', parseErr)
}
} catch (e) {
console.warn('解析流数据异常:', e)
}
},
onError: (err) => {
clearTimeout(timeout)
if (!isResolved) {
errorOccurred = true
ctrl.abort()
const errorMsg = err?.message || '网络请求失败'
console.error('SSE请求错误:', err)
message.error(errorMsg)
reject(new Error(errorMsg))
}
},
onClose: () => {
clearTimeout(timeout)
if (!isResolved) {
isResolved = true
resolve()
}
}
})
})
generatedContent.value = fullText.trim()
message.success('文案生成成功')
} catch (error) {
console.error('生成文案失败:', error)
message.error('生成文案失败,请重试')
} finally {
isLoading.value = false
}
}
// 获取当前输入值
function getCurrentInputValue() {
return (form.value.userInput || '').trim()
}
// 粗略判断是否为 URL含常见平台域名或 http(s) 开头)
function isLikelyUrl(value) {
if (!value) return false
const v = String(value).trim()
if (/^https?:\/\//i.test(v)) return true
return /(douyin\.com|bilibili\.com|youtube\.com|youtu\.be|tiktok\.com|ixigua\.com|v\.qq\.com)/i.test(v)
}
// 切换编辑模式
function toggleEditMode() {
if (!isEditMode.value) {
// 进入编辑模式
originalContent.value = generatedContent.value
editableContent.value = generatedContent.value
}
isEditMode.value = !isEditMode.value
}
// 保存编辑
function saveEdit() {
generatedContent.value = editableContent.value
isEditMode.value = false
message.success('文案已保存')
}
// 取消编辑
function cancelEdit() {
editableContent.value = originalContent.value
isEditMode.value = false
message.info('已取消编辑')
}
// 复制内容(编辑模式复制编辑区,否则复制生成内容),带降级方案
function copyContent() {
const text = isEditMode.value ? (editableContent.value || '') : (generatedContent.value || '')
if (!text.trim()) {
message.warning('没有可复制的内容')
return
}
// 优先使用异步 Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
message.success('文案已复制到剪贴板')
}).catch(() => {
// 降级到选中复制
fallbackCopy(text)
})
return
}
// 直接降级
fallbackCopy(text)
}
function fallbackCopy(text) {
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
if (ok) {
message.success('文案已复制到剪贴板')
} else {
message.error('复制失败,请手动复制')
}
} catch (e) {
console.warn('fallback copy failed:', e)
message.error('复制失败,请手动复制')
}
}
defineOptions({ name: 'ContentStyleCopywriting' })
</script>
<template>
<div class="copywriting-page">
<!-- 页面标题区域 -->
<!-- 主要内容区域 -->
<div class="main-content">
<a-row :gutter="16">
<a-col :lg="10" :md="24">
<div class="form-section">
<a-card class="form-card" :bordered="false" title="创作设置">
<a-form :model="form" layout="vertical" class="form-container">
<a-form-item class="form-item">
<template #label>
基础提示词
<span class="form-tip-inline">从视频分析提取的提示词将作为文案生成的基础参考</span>
</template>
<a-textarea
v-model:value="form.prompt"
placeholder="这里显示从视频分析中提取的创作提示词,将作为文案生成的基础参考"
:auto-size="{ minRows: 6, maxRows: 12 }"
class="custom-textarea"
/>
</a-form-item>
<!-- 统一输入文本或视频链接 -->
<a-form-item class="form-item">
<template #label>
输入内容/视频链接
<span class="form-tip-inline">输入要生成的主题/段落或粘贴视频链接以自动转写</span>
</template>
<a-textarea
v-model:value="form.userInput"
placeholder="直接输入文字或粘贴视频链接抖音、B站、YouTube等"
:auto-size="{ minRows: 6, maxRows: 12 }"
class="custom-textarea"
/>
</a-form-item>
<!-- 幅度设置 -->
<a-form-item class="form-item">
<template #label>
创作幅度
<span class="form-tip-inline">调整创作幅度数值越大创意性越强</span>
</template>
<div class="amplitude-row">
<a-slider
v-model:value="form.amplitude"
:min="0"
:max="100"
:tooltip-formatter="(value) => `${value}%`"
style="flex: 1"
/>
<a-input-number
v-model:value="form.amplitude"
:min="0"
:max="100"
style="width: 96px; margin-left: 12px;"
/>
</div>
</a-form-item>
<a-form-item class="form-item">
<a-button
type="primary"
@click="generateCopywriting"
:disabled="!getCurrentInputValue() || isLoading"
:loading="isLoading"
block
size="large"
class="generate-btn"
>
<template v-if="!isLoading">
<span class="btn-icon"><GmIcon name="icon-sparkle" :size="16" /></span>
生成文案
</template>
<template v-else>
生成中...
</template>
</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
</a-col>
<a-col :lg="14" :md="24">
<div class="result-section">
<a-card class="result-card" :bordered="false" title="生成结果">
<template #extra>
<a-space>
<a-button v-if="generatedContent" @click="toggleEditMode" size="small" class="action-btn">
<span class="btn-icon"><GmIcon :name="isEditMode ? 'icon-eye' : 'icon-edit'" :size="14" /></span>
{{ isEditMode ? '预览' : '编辑' }}
</a-button>
<a-button v-if="generatedContent" @click="copyContent" size="small" class="action-btn copy-btn">
<span class="btn-icon"><GmIcon name="icon-copy" :size="14" /></span>
复制
</a-button>
</a-space>
</template>
<div v-if="generatedContent" class="result-content">
<!-- 编辑模式 -->
<div v-if="isEditMode" class="edit-mode">
<a-textarea
v-model:value="editableContent"
:auto-size="{ minRows: 15, maxRows: 30 }"
placeholder="在这里编辑生成的文案内容..."
class="edit-textarea"
/>
<div class="edit-actions">
<a-space>
<a-button @click="saveEdit" type="primary" size="small" class="save-btn">
<span class="btn-icon"><GmIcon name="icon-save" :size="14" /></span>
保存
</a-button>
<a-button @click="cancelEdit" size="small" class="cancel-btn">
<span class="btn-icon"><GmIcon name="icon-cancel" :size="14" /></span>
取消
</a-button>
</a-space>
</div>
</div>
<!-- 预览模式 -->
<div v-else class="generated-content" v-html="md.render(generatedContent)"></div>
</div>
<a-empty v-else class="custom-empty">
<template #description>
<div class="empty-description">
<div class="empty-icon"><GmIcon name="icon-empty-doc" :size="36" /></div>
<div class="empty-title">等待生成文案</div>
<div class="empty-desc">请选择内容类型并填写相关信息,然后点击"生成文案"按钮</div>
</div>
</template>
</a-empty>
</a-card>
</div>
</a-col>
</a-row>
</div>
</div>
</template>
<style scoped>
/* 页面整体样式 */
.copywriting-page {
background: var(--color-bg);
min-height: 100vh;
padding: 0;
}
/* 页面标题区域 */
.page-header {
padding: 20px 0 30px 0;
text-align: center;
}
.header-content {
max-width: 600px;
margin: 0 auto;
padding: 0 20px;
}
.page-title {
font-size: 2rem;
font-weight: 600;
color: var(--color-text);
margin: 0 0 8px 0;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.title-icon {
font-size: 2rem;
}
.page-subtitle {
font-size: 1rem;
color: var(--color-text-secondary);
margin: 0;
font-weight: 400;
}
/* 区域标题 */
.section-header {
margin-bottom: 16px;
}
.section-icon {
font-size: 1.2rem;
margin-bottom: 4px;
display: block;
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
color: #6b7280;
margin: 0 0 4px 0;
}
.section-desc {
font-size: 0.9rem;
color: #9ca3af;
margin: 0;
font-weight: 400;
}
/* 卡片样式 */
.form-card,
.result-card {
background: var(--color-surface);
border-radius: 8px;
box-shadow: var(--shadow-inset-card);
border: 1px solid var(--color-border);
transition: all 0.3s ease;
}
.form-card:hover,
.result-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.form-card :deep(.ant-card-head),
.result-card :deep(.ant-card-head) {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 16px 20px;
}
.form-card :deep(.ant-card-head-title),
.result-card :deep(.ant-card-head-title) {
font-size: 1.05rem;
font-weight: 700;
color: var(--color-text);
}
.form-card :deep(.ant-card-body),
.result-card :deep(.ant-card-body) {
padding: 20px;
}
/* 表单容器 */
.form-container {
padding: 0;
}
.form-item {
margin-bottom: 20px;
}
.form-item :deep(.ant-form-item-label) {
padding-bottom: 8px;
}
.form-item :deep(.ant-form-item-label > label) {
font-weight: 600;
color: var(--color-text);
font-size: 14px;
}
/* 表单标签后的内联提示(不使用 emoji */
.form-tip-inline {
margin-left: 8px;
font-size: 12px;
color: var(--color-text-secondary);
font-weight: 400;
}
/* 自定义输入框样式 */
.custom-input,
.custom-textarea {
border-radius: 6px;
border: 1px solid var(--color-border);
transition: all 0.3s ease;
background: var(--color-surface);
color: var(--color-text);
}
.custom-input:focus,
.custom-textarea:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-glow);
background: var(--color-surface);
}
.custom-input:hover,
.custom-textarea:hover {
border-color: var(--color-border);
background: var(--color-surface);
}
.custom-textarea::placeholder {
color: var(--color-text-secondary);
}
/* 已合并输入:移除单选组相关样式 */
/* 输入区域动画 */
.input-section {
animation: slideIn 0.5s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 旧的块级提示兼容(如他处已有复用时可沿用) */
.form-tip {
display: none;
}
/* 生成按钮样式 */
.generate-btn {
margin-top: 16px;
height: 40px;
border-radius: 6px;
background: var(--color-primary);
border: none;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
}
.generate-btn:hover {
background: var(--color-primary);
box-shadow: var(--glow-primary);
}
.generate-btn:active {
background: var(--color-primary);
}
.btn-icon {
display: inline-flex;
align-items: center;
font-size: 16px;
}
/* 按钮禁用态保持主色,不换颜色(仅本页) */
:deep(.ant-btn-primary[disabled]),
:deep(.ant-btn-primary[disabled]:hover),
:deep(.ant-btn-primary[disabled]:active),
:deep(.ant-btn-primary.ant-btn-disabled),
:deep(.ant-btn-primary.ant-btn-disabled:hover),
:deep(.ant-btn-primary.ant-btn-disabled:active) {
background: var(--color-primary) !important;
border-color: var(--color-primary) !important;
color: #fff !important;
opacity: 0.6; /* 仅降低不透明度,不改变颜色 */
cursor: not-allowed;
box-shadow: none !important;
}
/* 操作按钮样式 */
.action-btn {
border-radius: 6px;
transition: all 0.3s ease;
font-weight: 500;
}
.action-btn:hover {
background: rgba(59, 130, 246, 0.08);
}
.copy-btn:hover {
background: rgba(59, 130, 246, 0.12);
border-color: var(--color-primary);
color: #fff;
}
/* 幅度滑块样式 */
.amplitude-row {
display: flex;
align-items: center;
gap: 12px;
}
/* 暗色下滑块可见性增强 */
:deep(.ant-slider) {
padding: 10px 0; /* 增加垂直留白,减少拥挤感 */
}
:deep(.ant-slider-rail) {
background-color: #252525; /* 未选中轨道更深,增强对比 */
height: 4px;
}
:deep(.ant-slider-track) {
background-color: var(--color-primary);
height: 4px;
}
:deep(.ant-slider:hover .ant-slider-track) {
background-color: var(--color-primary);
}
:deep(.ant-slider-handle::after) {
box-shadow: 0 0 0 2px var(--color-primary);
}
:deep(.ant-slider-handle:focus-visible::after),
:deep(.ant-slider-handle:hover::after),
:deep(.ant-slider-handle:active::after) {
box-shadow: 0 0 0 3px var(--color-primary-glow);
}
/* 空状态样式 */
.custom-empty {
padding: 40px 20px;
}
.empty-description {
text-align: center;
padding: 20px;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 16px;
}
.empty-title {
font-size: 16px;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
.empty-desc {
font-size: 14px;
color: #6b7280;
line-height: 1.6;
max-width: 300px;
margin: 0 auto;
}
/* 编辑模式样式 */
.edit-mode {
padding: 0;
background: transparent;
border-radius: 8px;
}
.edit-textarea {
margin-bottom: 12px;
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
font-size: 14px;
line-height: 1.6;
}
.edit-textarea:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-glow);
background: var(--color-surface);
color: var(--color-text);
}
.edit-textarea:hover {
border-color: var(--color-border);
}
.edit-textarea::placeholder {
color: var(--color-text-secondary);
}
.edit-actions {
text-align: right;
padding-top: 12px;
border-top: 1px solid var(--color-border);
margin-top: 12px;
}
.save-btn {
background: var(--color-primary);
border: 1px solid var(--color-primary);
border-radius: 6px;
font-weight: 500;
}
.save-btn:hover {
background: var(--color-primary);
filter: brightness(1.04);
box-shadow: var(--glow-primary);
}
.cancel-btn {
border-radius: 6px;
font-weight: 500;
}
/* 生成内容样式 */
.result-content {
min-height: 400px;
}
.generated-content {
padding: 24px;
background: #111111;
border-radius: 8px;
border: 1px solid var(--color-border);
line-height: 1.9;
color: #f5f5f5;
min-height: 400px;
font-size: 15.5px;
white-space: pre-wrap;
word-break: break-word;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.generated-content :deep(h1) {
font-size: 22px;
font-weight: 600;
margin-bottom: 16px;
color: #ffffff;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.generated-content :deep(h2) {
font-size: 19px;
font-weight: 600;
margin: 22px 0 12px 0;
color: #fff;
padding-bottom: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.generated-content :deep(h3) {
font-size: 16px;
font-weight: 600;
margin: 18px 0 10px 0;
color: #efefef;
}
.generated-content :deep(p) {
margin: 12px 0 14px 0;
color: #e3e6ea;
line-height: 1.9;
font-size: 15.5px;
}
.generated-content :deep(ul),
.generated-content :deep(ol) {
margin: 12px 0;
padding-left: 24px;
}
.generated-content :deep(li) {
margin: 6px 0;
color: #e3e6ea;
line-height: 1.9;
font-size: 15.5px;
}
.generated-content :deep(strong) {
font-weight: 600;
color: #ffffff;
}
.generated-content :deep(code) {
background: #0b0b0b;
padding: 3px 8px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13.5px;
color: #ffb86c;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.generated-content :deep(pre) {
background: #0b0b0b;
padding: 16px 18px;
border-radius: 6px;
overflow-x: auto;
margin: 12px 0;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.generated-content :deep(pre code) {
background: transparent;
padding: 0;
border: none;
color: #ffb86c;
}
.generated-content :deep(blockquote) {
margin: 16px 0;
padding: 12px 16px;
background: rgba(22, 119, 255, 0.12);
border-left: 4px solid var(--color-primary);
border-radius: 0 6px 6px 0;
font-style: italic;
color: #e3e6ea;
}
/* 响应式设计 */
@media (max-width: 768px) {
.page-header {
padding: 16px 0 24px 0;
}
.page-title {
font-size: 1.5rem;
flex-direction: column;
gap: 6px;
}
.title-icon {
font-size: 1.5rem;
}
.main-content {
padding: 0 16px 24px 16px;
}
.form-card,
.result-card {
margin-bottom: 16px;
}
.form-card :deep(.ant-card-body),
.result-card :deep(.ant-card-body) {
padding: 16px;
}
.radio-label {
padding: 10px 12px;
}
.radio-icon {
font-size: 1rem;
width: 28px;
height: 28px;
margin-right: 10px;
}
.generate-btn {
height: 36px;
font-size: 13px;
}
.generated-content {
padding: 14px;
font-size: 15px;
}
}
</style>

View File

@@ -0,0 +1,25 @@
<script setup>
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">生成数字人</h2>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<section class="bg-white p-4 rounded shadow lg:col-span-1">
<div class="space-y-3">
<div class="text-gray-600 text-sm">形象背景脚本分辨率字幕等配置</div>
<button class="px-4 py-2 bg-purple-600 text-white rounded">生成视频</button>
</div>
</section>
<section class="bg-white p-4 rounded shadow lg:col-span-2">
<div class="text-gray-500">视频预览任务队列渲染进度</div>
</section>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,313 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { useVoiceCopyStore } from '@/stores/voiceCopy'
import useVoiceText from '@gold/hooks/web/useVoiceText'
const store = useVoiceCopyStore()
const profiles = computed(() => store.profiles)
const activeId = computed(() => store.activeId)
const { getVoiceText } = useVoiceText()
const form = reactive({
id: '',
name: '',
language: 'zh-CN', // 简体中文
gender: 'female',
referenceAudio: '', // dataURL 或外链
sampleText: '今天天气很好,我们一起去公园散步吧。',
enhancement: 50, // 增强强度 0-100去噪/清晰度)
noiseReduction: true,
note: '',
originalText: '', // 原语音文本
})
const isSavingAs = ref(false)
const saveAsName = ref('')
// function generateId() {
// return `${Date.now()}_${Math.floor(Math.random()*1e6)}`
// }
async function loadProfiles() { await store.load(); if (store.activeProfile) Object.assign(form, { ...store.activeProfile }) }
function toDataURL(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
async function onUploadBefore(file) {
try {
const dataUrl = await toDataURL(file)
form.referenceAudio = dataUrl
message.success('音频已就绪(本地预处理)')
} catch {
message.error('读取音频失败,请重试')
}
return false // 阻止 antd 自动上传
}
function resetForm() {
Object.assign(form, {
id: '',
name: '',
language: 'zh-CN',
gender: 'female',
referenceAudio: '',
sampleText: '今天天气很好,我们一起去公园散步吧。',
enhancement: 50,
noiseReduction: true,
note: '',
originalText: '',
})
}
function validate() {
if (!form.referenceAudio) {
message.warning('请先上传或粘贴一段参考音频')
return false
}
return true
}
async function transcribeOriginal() {
const url = (form.referenceAudio || '').trim()
if (!url) { message.warning('请先提供参考音频'); return }
if (!/^https?:\/\//i.test(url)) {
message.info('当前仅支持网络音频链接一键转写,请粘贴 http(s) 链接')
return
}
try {
const list = await getVoiceText([{ audio_url: url }])
const text = Array.isArray(list) && list[0]?.value ? list[0].value : ''
if (text) {
form.originalText = text
message.success('已获取原语音文本')
} else {
message.warning('未获取到可用文本,请稍后重试')
}
} catch (e) {
console.error(e)
message.error('转写失败,请稍后重试')
}
}
async function saveProfile() {
if (!validate()) return
if (!form.id) {
const created = await store.add({ ...form, id: '' })
Object.assign(form, { ...created })
message.success('已保存到本地')
} else {
const updated = await store.update({ ...form })
Object.assign(form, { ...updated })
message.success('已更新')
}
}
function openSaveAs() {
if (!validate()) return
saveAsName.value = form.name ? `${form.name}-副本` : ''
isSavingAs.value = true
}
async function confirmSaveAs() {
const name = (saveAsName.value || '').trim()
if (!name) {
message.warning('请输入名称')
return
}
const created = await store.duplicate({ ...form }, name)
Object.assign(form, { ...created })
isSavingAs.value = false
message.success('已另存为')
}
function selectProfile(p) {
if (!p) return
store.select(p.id)
Object.assign(form, { ...p })
}
function requestDelete(p) {
Modal.confirm({
title: '删除克隆声音',
content: `确定删除「${p.name || '未命名'}」吗?此操作不可恢复。`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
await store.remove(p.id)
if (!store.activeProfile) { resetForm() } else { Object.assign(form, { ...store.activeProfile }) }
message.success('已删除')
}
})
}
onMounted(loadProfiles)
</script>
<template>
<div class="vc-page">
<div class="vc-grid">
<!-- 左侧Cosy Voice 表单 -->
<section class="vc-left">
<div class="vc-title">语音克隆</div>
<div class="vc-steps">
<span class="step">1. 上传音频</span>
<span class="step">2. 填文本</span>
<span class="step">3. 保存</span>
</div>
<a-form layout="vertical">
<a-form-item label="名称(可选)">
<a-input v-model:value="form.name" placeholder="给你的克隆声音起个名字" allow-clear />
</a-form-item>
<a-form-item label="语言">
<a-select v-model:value="form.language" :options="[
{ value: 'zh-CN', label: '简体中文' },
{ value: 'zh-TW', label: '繁體中文' },
{ value: 'en-US', label: 'English' }
]" />
</a-form-item>
<a-form-item label="性别/音色">
<a-radio-group v-model:value="form.gender">
<a-radio value="female">女声更柔和</a-radio>
<a-radio value="male">男声更低沉</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="参考音频">
<a-upload :before-upload="onUploadBefore" :show-upload-list="false" accept="audio/*">
<a-button type="default">选择音频文件</a-button>
</a-upload>
<a-input v-model:value="form.referenceAudio" placeholder="或粘贴音频外链" style="margin-top:8px" />
<div v-if="form.referenceAudio" class="vc-audio-preview">
<audio :src="form.referenceAudio" controls preload="metadata" />
</div>
</a-form-item>
<a-form-item label="示例文本(可读一段样例,提升效果)">
<a-textarea v-model:value="form.sampleText" :rows="3" placeholder="示例:今天天气很好,我们一起去公园散步吧。" />
</a-form-item>
<a-form-item label="原语音文本">
<a-textarea v-model:value="form.originalText" :rows="4" placeholder="填写参考音频中的发音文本" />
<div class="vc-row" style="margin-top:8px">
<a-button @click="transcribeOriginal">一键转写</a-button>
<div class="hint" style="margin-left:12px">仅支持网络链接</div>
</div>
</a-form-item>
<div class="vc-row">
<a-form-item label="去噪优化" style="flex:1">
<a-switch v-model:checked="form.noiseReduction" />
</a-form-item>
<a-form-item label="增强强度" style="flex:2">
<a-slider v-model:value="form.enhancement" :min="0" :max="100" />
</a-form-item>
</div>
<a-form-item label="备注(可选)">
<a-textarea v-model:value="form.note" :rows="2" />
</a-form-item>
<div class="vc-actions">
<a-space>
<a-button type="primary" @click="saveProfile">保存</a-button>
<a-button @click="openSaveAs">另存为</a-button>
<a-button @click="resetForm">重置</a-button>
</a-space>
<div class="hint" style="margin-top:6px">保存后可在右侧管理</div>
</div>
</a-form>
</section>
<!-- 右侧已保存的克隆声音列表 -->
<section class="vc-right">
<div class="vc-title">已保存的克隆声音</div>
<div v-if="!profiles.length" class="vc-empty">暂无记录保存后会出现在这里</div>
<ul v-else class="vc-list">
<li v-for="p in profiles" :key="p.id" class="vc-item" :class="{ active: p.id === activeId }" @click="selectProfile(p)">
<div class="vc-item-main">
<div class="vc-item-name">{{ p.name || '未命名' }}</div>
<div class="vc-item-sub">{{ p.language }} · {{ p.gender === 'female' ? '女声' : '男声' }}</div>
</div>
<button class="vc-item-del" @click.stop="requestDelete(p)" aria-label="删除">×</button>
</li>
</ul>
</section>
</div>
<a-modal v-model:open="isSavingAs" title="另存为" :maskClosable="false" @ok="confirmSaveAs" @cancel="() => (isSavingAs = false)">
<a-input v-model:value="saveAsName" placeholder="输入名称,如:小红-普通话-温柔女声" />
</a-modal>
</div>
</template>
<style scoped>
.vc-page { color: var(--color-text); }
.vc-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (min-width: 1024px) {
.vc-grid { grid-template-columns: 1fr 1fr; }
}
.vc-left, .vc-right {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-inset-card);
padding: 16px;
}
.vc-title { font-size: 14px; color: var(--color-text-secondary); margin-bottom: 8px; }
.vc-steps { display:flex; flex-wrap: wrap; gap:8px; margin-bottom: 8px; }
.vc-steps .step { font-size: 12px; color: var(--color-text-secondary); background: #161616; border: 1px solid var(--color-border); padding: 4px 8px; border-radius: 999px; }
.vc-row { display: flex; gap: 16px; }
.vc-actions { margin-top: 8px; }
.hint { font-size: 12px; color: var(--color-text-secondary); margin-top: 6px; }
.vc-audio-preview { margin-top: 8px; }
.vc-audio-preview audio { width: 100%; }
.vc-empty { color: var(--color-text-secondary); padding: 12px; }
.vc-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
.vc-item {
display: flex; align-items: center; justify-content: space-between;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
padding: 10px 12px; cursor: pointer;
transition: background .15s ease, border-color .15s ease, transform .1s ease;
}
.vc-item:hover { background: #161616; }
.vc-item.active { border-color: var(--color-primary); }
.vc-item-main { display: flex; flex-direction: column; }
.vc-item-name { font-size: 14px; color: var(--color-text); font-weight: 600; }
.vc-item-sub { font-size: 12px; color: var(--color-text-secondary); }
.vc-item-del {
visibility: hidden;
width: 24px; height: 24px; border-radius: 6px;
background: #2a2a2a; color: #fff; border: 1px solid var(--color-border);
}
.vc-item:hover .vc-item-del { visibility: visible; }
.vc-item-del:hover { background: #3a3a3a; }
</style>

View File

@@ -0,0 +1,146 @@
<script setup>
import { ref, computed, h, watch } from 'vue'
import { message } from 'ant-design-vue'
import { useVoiceCopyStore } from '@/stores/voiceCopy'
const store = useVoiceCopyStore()
const selectedVoiceId = ref(store.activeId)
const text = ref('')
const speed = ref(1.0)
const emotion = ref('neutral')
const records = ref([]) // 生成记录:{ id, voiceId, voiceName, text, status, url, createdAt }
const voiceOptions = computed(() => store.profiles.map(p => ({ value: p.id, label: p.name || '未命名' })))
const selectedVoice = computed(() => store.profiles.find(p => p.id === selectedVoiceId.value) || null)
// 监听 store.activeId 变化,同步到 selectedVoiceId
watch(() => store.activeId, (newId) => {
if (newId && store.profiles.find(p => p.id === newId)) {
selectedVoiceId.value = newId
}
}, { immediate: true })
function ensureReady() {
if (!selectedVoiceId.value) { message.warning('请选择克隆声音'); return false }
if (!text.value.trim()) { message.warning('请输入文案'); return false }
return true
}
function simulateGenerate() {
// 本地模拟:新增记录 → 排队 → 处理中 → 完成
const id = `${Date.now()}_${Math.floor(Math.random()*1e5)}`
const now = Date.now()
const rec = {
id,
voiceId: selectedVoiceId.value,
voiceName: selectedVoice.value?.name || '未命名',
text: text.value.trim(),
status: 'queued',
url: '',
createdAt: now,
}
records.value = [rec, ...records.value]
setTimeout(() => {
updateRecord(id, { status: 'processing' })
}, 600)
setTimeout(() => {
// 模拟成功产出
const blob = new Blob([new Uint8Array([1,2,3])], { type: 'audio/mp3' })
const url = URL.createObjectURL(blob)
updateRecord(id, { status: 'done', url })
message.success('生成完成(模拟)')
}, 2200)
}
function updateRecord(id, patch) {
const idx = records.value.findIndex(r => r.id === id)
if (idx !== -1) records.value[idx] = { ...records.value[idx], ...patch }
}
function formatTime(ts) {
const d = new Date(ts)
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
function onGenerate() {
if (!ensureReady()) return
simulateGenerate()
}
</script>
<template>
<div class="vg-page">
<div class="vg-grid">
<!-- 左侧配置区 -->
<section class="vg-left">
<div class="vg-title">配音生成</div>
<a-form layout="vertical">
<a-form-item label="克隆声音">
<a-select v-model:value="selectedVoiceId" :options="voiceOptions" placeholder="请选择已保存的克隆声音" />
</a-form-item>
<a-form-item label="文案">
<a-textarea v-model:value="text" :rows="5" placeholder="输入要合成的文案" />
</a-form-item>
<div class="vg-row">
<a-form-item label="语速" style="flex:1">
<a-slider v-model:value="speed" :min="0.5" :max="2" :step="0.1" />
</a-form-item>
<a-form-item label="情感" style="flex:1">
<a-select v-model:value="emotion" :options="[
{ value: 'neutral', label: '中性' },
{ value: 'happy', label: '开心' },
{ value: 'sad', label: '悲伤' },
{ value: 'angry', label: '愤怒' }
]" />
</a-form-item>
</div>
<a-space>
<a-button type="primary" @click="onGenerate">生成</a-button>
</a-space>
</a-form>
</section>
<!-- 右侧结果/预览与记录 -->
<section class="vg-right">
<div class="vg-title">生成记录</div>
<template v-if="records.length">
<a-table :dataSource="records" :pagination="false" rowKey="id">
<a-table-column key="createdAt" title="时间" :customRender="({ record }) => formatTime(record.createdAt)" />
<a-table-column key="voiceName" title="声音" dataIndex="voiceName" />
<a-table-column key="text" title="文案" :customRender="({ record }) => record.text?.slice(0, 40) + (record.text?.length>40?'...':'')" />
<a-table-column key="status" title="状态" :customRender="({ record }) => record.status" />
<a-table-column key="action" title="操作"
:customRender="({ record }) => record.url ? h('a', { href: record.url, target: '_blank' }, '预览') : h('span', {}, '-')" />
</a-table>
</template>
<a-empty v-else class="vg-empty">
<template #description>
<div>暂无生成记录选择声音并输入文案后点击生成</div>
</template>
</a-empty>
</section>
</div>
</div>
</template>
<style scoped>
.vg-page { color: var(--color-text); }
.vg-grid { display: grid; grid-template-columns: 1fr; gap: 16px; }
@media (min-width: 1024px) { .vg-grid { grid-template-columns: 1fr 1fr; } }
.vg-left, .vg-right {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-inset-card);
padding: 16px;
}
.vg-title { font-size: 14px; color: var(--color-text-secondary); margin-bottom: 8px; }
.vg-row { display: flex; gap: 16px; }
</style>

View File

@@ -0,0 +1,781 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
defineOptions({
name: 'HomeBusiness'
})
const kpis = ref([
{ label: '企业客户', value: '1,200+' },
{ label: '月度生成内容', value: '3,5M+' },
{ label: '平均提效', value: '↑ 67%' },
])
const features = ref([
{ title: '内容风格洞察', desc: '结构化拆解头部账号,提炼风格要素与提示词', icon: '📊' },
{ title: '热点趋势分析', desc: '全网热度追踪,关联话题、时段与平台建议', icon: '📈' },
{ title: 'AI文案与脚本', desc: '场景化模板与知识库支持,一键生成可用稿', icon: '📝' },
{ title: '数字人&配音', desc: '声音训练、语音合成、数字人出镜一体化', icon: '🎙️' },
])
// 轮播数据(商务风渐变 + 文案)
const banners = ref([
{ id: 1, title: '内容风格分析', desc: '结构特征与关键词洞察', color: 'from-indigo-600 via-indigo-500 to-sky-500' },
{ id: 2, title: '热点趋势分析', desc: '热度变化与预测建议', color: 'from-fuchsia-600 via-fuchsia-500 to-pink-500' },
{ id: 3, title: '数字人与配音', desc: '声音训练与数字人视频', color: 'from-emerald-600 via-emerald-500 to-teal-500' },
])
const active = ref(0)
let timer = null
onMounted(() => {
timer = setInterval(() => {
active.value = (active.value + 1) % banners.value.length
}, 4500)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<template>
<div class="mt-4 home">
<!-- Hero 大气商务风 -->
<section class="hero-section fade-in">
<div class="hero-bg"></div>
<div class="hero-ornaments">
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
<div class="grid-lines"></div>
</div>
<div class="hero-gradient">
<div class="container">
<div class="hero-grid">
<!-- 左侧文案 -->
<div class="hero-left">
<div class="badge">
<span class="badge-dot"></span>
企业级内容智能平台
</div>
<h1 class="hero-title">
让每一次创作兼具<span class="gradient-text">效率</span><span class="gradient-text-2">品质</span>
</h1>
<p class="hero-desc">
金牌内容大师面向品牌与机构提供从策略到产出的全链路内容能力对标洞察趋势分析AI脚本数字人及配音助力营销持续增长
</p>
<div class="hero-actions">
<button class="btn-primary">
立即体验
</button>
<button class="btn-ghost">
安全与合规
</button>
</div>
<!-- 关键信息 KPI -->
<div class="kpi-grid">
<div v-for="k in kpis" :key="k.label" class="antd-card glass kpi-item">
<div class="kpi-value">{{ k.value }}</div>
<div class="kpi-label">{{ k.label }}</div>
</div>
</div>
</div>
<!-- 右侧示意卡片 -->
<div class="hero-right">
<!-- 轮播图大气商务风 -->
<div class="carousel antd-shadow">
<div v-for="(b, i) in banners" :key="b.id" class="banner"
:class="['banner--' + b.id, { 'is-active': i === active }]">
<div class="banner-content">
<div class="banner-text">
<div class="banner-title">{{ b.title }}</div>
<div class="banner-desc">{{ b.desc }}</div>
</div>
</div>
</div>
<!-- 指示点 -->
<div class="dots">
<button v-for="(b, i) in banners" :key="'dot-' + b.id" class="dot"
:class="{ 'dot--active': i === active }" @click="active = i" aria-label="切换轮播"></button>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 核心能力栅格 -->
<section class="core-section fade-in">
<div class="features-grid">
<div v-for="f in features" :key="f.title" class="antd-card glass feature-item">
<div class="feature-icon">{{ f.icon }}</div>
<div class="feature-title">{{ f.title }}</div>
<div class="feature-desc">{{ f.desc }}</div>
<div class="feature-link">了解更多</div>
</div>
</div>
</section>
<!-- 品牌背书与合规提示 -->
<section class="brand-compliance fade-in">
<div class="brand-card antd-card">
<div class="brand-title">服务过的行业伙伴</div>
<div class="logos-grid">
<div v-for="i in 12" :key="i" class="logo-item antd-card">LOGO</div>
</div>
</div>
<div class="compliance-card antd-shadow">
<div class="compliance-title">数据与合规</div>
<ul class="compliance-list">
<li>· 严格遵循平台与数据合规要求</li>
<li>· 支持企业私有化部署与访问控制</li>
<li>· 可定制审计留痕与内容风控</li>
</ul>
<button class="btn-primary compliance-button">查看白皮书</button>
</div>
</section>
</div>
</template>
<style scoped>
/* antd 阴影与卡片风格 */
.antd-shadow {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: box-shadow 0.2s ease;
}
.antd-shadow:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
.antd-card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
}
.antd-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.16);
transform: translateY(-1px);
border-color: #e6f4ff;
}
/* 按钮风格(主色 #1890FFhover #40A9FF */
.btn-primary {
padding: 12px 20px;
border-radius: 8px;
background: linear-gradient(90deg, #1890FF, #40A9FF);
color: #fff;
font-weight: 600;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.35);
transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease;
}
.btn-primary:hover {
transform: translateY(-1px) scale(1.02);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.45);
filter: brightness(1.02);
}
.btn-ghost {
padding: 12px 20px;
border-radius: 8px;
background: #ffffff;
color: #1890FF;
font-weight: 600;
border: 1px solid #91D5FF;
transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.btn-ghost:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
background-color: #E6F7FF;
}
/* 页面进入动画与可交互动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.5s ease forwards;
}
/* ======= 首页自定义样式(替代 Tailwind ======= */
.home {
display: flex;
flex-direction: column;
gap: 32px;
}
.hero-section {
position: relative;
overflow: hidden;
border-radius: 16px;
}
.hero-bg {
position: absolute;
inset: 0;
background: radial-gradient(900px 300px at 0% 0%, rgba(24, 144, 255, 0.08), transparent);
}
/* 装饰性模糊色块与细网格,增强时尚层次感 */
.hero-ornaments {
position: absolute;
inset: 0;
pointer-events: none;
}
.hero-ornaments .blob {
position: absolute;
filter: blur(40px);
opacity: 0.5;
border-radius: 50%;
}
.hero-ornaments .blob-1 {
width: 240px;
height: 240px;
left: -40px;
top: -60px;
background: radial-gradient(circle at 30% 30%, rgba(99,102,241,.55), rgba(14,165,233,.25));
}
.hero-ornaments .blob-2 {
width: 280px;
height: 280px;
right: -60px;
bottom: -80px;
background: radial-gradient(circle at 70% 70%, rgba(16,185,129,.5), rgba(20,184,166,.25));
}
.hero-ornaments .grid-lines {
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(24,144,255,0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(24,144,255,0.06) 1px, transparent 1px);
background-size: 24px 24px, 24px 24px;
-webkit-mask-image: radial-gradient(ellipse at center, black 40%, transparent 70%);
mask-image: radial-gradient(ellipse at center, black 40%, transparent 70%);
}
.hero-gradient {
background: linear-gradient(180deg, #E6F7FF 0%, #FFFFFF 100%);
}
.container {
padding: 40px 16px;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
}
@media (min-width: 768px) {
.container {
padding: 48px 32px;
}
}
@media (min-width: 1024px) {
.container {
padding: 64px 48px;
}
}
.hero-grid {
display: grid;
gap: 40px;
align-items: center;
}
@media (min-width: 1024px) {
.hero-grid {
grid-template-columns: 1fr 1fr;
gap: 40px;
}
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #1890FF;
background: #E6F7FF;
padding: 4px 12px;
border-radius: 999px;
box-shadow: inset 0 0 0 1px #91D5FF;
}
.badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #1890FF;
display: inline-block;
}
.hero-title {
margin-top: 16px;
color: #111827;
font-weight: 800;
line-height: 1.2;
font-size: 24px;
letter-spacing: -0.01em;
}
@media (min-width: 768px) {
.hero-title {
font-size: 32px;
}
}
@media (min-width: 1024px) {
.hero-title {
font-size: 40px;
}
}
.text-primary {
color: #1890FF;
}
.text-accent {
color: #FA8C16;
}
/* 渐变文本:更具时尚感 */
.gradient-text {
background: linear-gradient(90deg, #1677ff, #22d3ee);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.gradient-text-2 {
background: linear-gradient(90deg, #f97316, #ef4444);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero-desc {
margin-top: 16px;
font-size: 16px;
color: #4B5563;
line-height: 1.75;
}
.hero-actions {
margin-top: 24px;
display: flex;
align-items: center;
gap: 16px;
}
.kpi-grid {
margin-top: 32px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.kpi-item {
padding: 16px;
text-align: center;
}
.kpi-item:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.kpi-value {
font-size: 24px;
font-weight: 700;
color: #111827;
}
.kpi-label {
margin-top: 4px;
font-size: 14px;
color: #6B7280;
}
.carousel {
position: relative;
height: 224px;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(145, 213, 255, 0.4);
}
@media (min-width: 640px) {
.carousel {
height: 256px;
}
}
@media (min-width: 768px) {
.carousel {
height: 288px;
}
}
@media (min-width: 1024px) {
.carousel {
height: 320px;
}
}
.banner {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 0.7s ease-in-out;
display: flex;
}
.banner.is-active {
opacity: 1;
}
.banner-content {
width: 100%;
height: 100%;
display: flex;
align-items: center;
color: #111827;
position: relative;
}
/* 玻璃覆层叠加彩色背景,弱化纯色感 */
.banner-content::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.18), rgba(255,255,255,0.06));
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
}
.banner-text {
padding-left: 24px;
padding-right: 24px;
}
@media (min-width: 768px) {
.banner-text {
padding-left: 40px;
padding-right: 40px;
}
}
@media (min-width: 1024px) {
.banner-text {
padding-left: 48px;
padding-right: 48px;
}
}
.banner-title {
font-size: 24px;
font-weight: 800;
letter-spacing: -0.01em;
}
@media (min-width: 768px) {
.banner-title {
font-size: 28px;
}
}
.banner-desc {
margin-top: 8px;
font-size: 16px;
color: rgba(55, 65, 81, 0.9);
}
.banner--1 .banner-content {
background: linear-gradient(90deg, #4F46E5 0%, #6366F1 50%, #0EA5E9 100%);
}
.banner--2 .banner-content {
background: linear-gradient(90deg, #A21CAF 0%, #D946EF 50%, #EC4899 100%);
}
.banner--3 .banner-content {
background: linear-gradient(90deg, #059669 0%, #10B981 50%, #14B8A6 100%);
}
.dots {
position: absolute;
bottom: 12px;
right: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.dot {
height: 10px;
width: 10px;
border-radius: 999px;
background: #91D5FF;
transition: all 0.2s ease;
}
.dot--active {
width: 24px;
background: #1890FF;
}
.core-section {
display: block;
}
.features-grid {
display: grid;
gap: 24px;
}
@media (min-width: 768px) {
.features-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.features-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.feature-item {
padding: 16px;
}
.feature-item {
transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease;
}
.feature-item:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(17,24,39,.12);
border-color: rgba(99, 102, 241, 0.25);
}
.feature-icon {
font-size: 24px;
line-height: 1;
}
.feature-title {
margin-top: 12px;
font-size: 20px;
font-weight: 600;
color: #111827;
}
.feature-desc {
margin-top: 4px;
font-size: 16px;
color: #4B5563;
line-height: 1.75;
}
.feature-link {
margin-top: 12px;
font-size: 14px;
color: #1890FF;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
position: relative;
padding-right: 18px;
}
.feature-link:hover {
text-decoration: none;
transform: translateX(2px);
}
.feature-link::after {
content: "→";
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
transition: transform .2s ease;
}
.feature-link:hover::after {
transform: translate(3px, -50%);
}
.brand-compliance {
display: grid;
gap: 24px;
}
@media (min-width: 1024px) {
.brand-compliance {
grid-template-columns: 2fr 1fr;
}
}
.brand-card {
padding: 24px;
}
.brand-title {
font-size: 14px;
color: #6B7280;
}
.logos-grid {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
@media (min-width: 640px) {
.logos-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 768px) {
.logos-grid {
grid-template-columns: repeat(6, 1fr);
}
}
.logo-item {
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #6B7280;
filter: grayscale(0.9);
transition: filter .2s ease, transform .2s ease, box-shadow .2s ease;
}
.compliance-card {
border-radius: 12px;
background: linear-gradient(135deg, #E6F7FF 0%, #FFFFFF 100%);
padding: 24px;
border: 1px solid rgba(145, 213, 255, 0.6);
}
.logo-item:hover {
filter: grayscale(0);
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0,0,0,0.08);
}
/* 玻璃拟态:统一视觉语汇 */
.glass {
background: rgba(255, 255, 255, 0.6);
-webkit-backdrop-filter: saturate(140%) blur(8px);
backdrop-filter: saturate(140%) blur(8px);
border-color: rgba(145, 213, 255, 0.6);
}
/* 入场动画特性卡和KPI稍后淡入上移强化层次 */
@keyframes floatIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.kpi-item, .feature-item { opacity: 0; animation: floatIn .45s ease forwards; }
.kpi-item:nth-child(1), .feature-item:nth-child(1) { animation-delay: .02s; }
.kpi-item:nth-child(2), .feature-item:nth-child(2) { animation-delay: .08s; }
.kpi-item:nth-child(3), .feature-item:nth-child(3) { animation-delay: .14s; }
.feature-item:nth-child(4) { animation-delay: .2s; }
.compliance-title {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.compliance-list {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 16px;
color: #374151;
}
.compliance-button {
margin-top: 16px;
}
</style>
<style scoped>
/* 暗色覆盖(不破坏原有类名,基于变量快速换肤) */
.home {
color: var(--color-text);
}
/* 卡片与玻璃风格在暗色下的表现 */
.home .antd-card { background: var(--color-surface); border-color: var(--color-border); }
.home .glass { background: rgba(26,26,26,0.66); border-color: var(--color-border); }
/* 英雄区与渐变、徽标与说明文字 */
.home .hero-gradient { background: linear-gradient(180deg, rgba(26,102,224,0.10) 0%, rgba(0,0,0,0) 60%) !important; }
.home .hero-title { color: var(--color-text) !important; }
.home .hero-desc { color: var(--color-text-secondary) !important; }
.home .badge { color: var(--color-blue) !important; background: rgba(26,102,224,0.12) !important; box-shadow: inset 0 0 0 1px rgba(26,102,224,0.35) !important; }
.home .badge-dot { background: var(--color-blue) !important; }
/* KPI 与特性卡片文案色 */
.home .kpi-value { color: var(--color-text) !important; }
.home .kpi-label { color: var(--color-text-secondary) !important; }
.home .feature-title { color: var(--color-text) !important; }
.home .feature-desc { color: var(--color-text-secondary) !important; }
.home .feature-link { color: var(--color-blue) !important; }
/* 轮播与横幅 */
.home .carousel { border-color: var(--color-border) !important; }
.home .banner-content { color: var(--color-text) !important; }
.home .banner-content::after { background: linear-gradient(180deg, rgba(0,0,0,0.12), rgba(0,0,0,0.06)) !important; }
/* 品牌与合规区 */
.home .brand-title { color: var(--color-text-secondary) !important; }
.home .logo-item { color: var(--color-text-secondary) !important; }
.home .compliance-card { background: var(--color-surface) !important; border-color: var(--color-border) !important; }
.home .compliance-title { color: var(--color-text) !important; }
.home .compliance-list { color: var(--color-text-secondary) !important; }
/* 主按钮与次要按钮 */
.home .btn-primary { background: var(--color-primary) !important; color: #fff !important; box-shadow: var(--glow-primary) !important; }
.home .btn-primary:hover { box-shadow: var(--glow-primary) !important; filter: brightness(1.03) !important; }
.home .btn-ghost { background: var(--color-surface) !important; color: var(--color-text-secondary) !important; border: 1px solid var(--color-border) !important; }
.home .btn-ghost:hover { background: #161616 !important; color: var(--color-text) !important; box-shadow: var(--glow-primary) !important; }
/* 生成内容区(若有) */
.home .generated-content :deep(h1) { color: var(--color-text) !important; }
.home .generated-content :deep(h2) { color: var(--color-text) !important; }
.home .generated-content :deep(h3) { color: var(--color-text-secondary) !important; }
.home .generated-content :deep(p) { color: var(--color-text-secondary) !important; }
</style>

View File

@@ -0,0 +1,15 @@
<script setup>
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">下载</h2>
<div class="bg-white p-4 rounded shadow">提供Windows与Mac安装包下载占位</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,15 @@
<script setup>
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">帮助与指南</h2>
<div class="bg-white p-4 rounded shadow">常见问题操作指引与联系支持</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,15 @@
<script setup>
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">主题设置</h2>
<div class="bg-white p-4 rounded shadow">浅色/深色切换与品牌色配置占位</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,22 @@
<script setup>
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">素材混剪</h2>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<section class="bg-white p-4 rounded shadow lg:col-span-1">
<div class="text-gray-600 text-sm">文案拆解与镜头建议</div>
</section>
<section class="bg-white p-4 rounded shadow lg:col-span-2">
<div class="text-gray-500">素材匹配与时间线导出到剪映</div>
</section>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,15 @@
<script setup>
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">实时热点推送</h2>
<div class="bg-white p-4 rounded shadow">榜单看板订阅管理趋势联动</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,16 @@
<script setup>
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">热点-文案创作</h2>
<p class="text-gray-600 text-sm">入口参数预填自热度/预测面板</p>
<div class="bg-white p-4 rounded shadow">结果编辑器与模块一共享逻辑</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,642 @@
<script setup>
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import TikhubService, { InterfaceType, MethodType, ParamType } from '@/api/tikhub'
// 定义组件名称
defineOptions({
name: 'ForecastView'
})
// 平台列表
// const platforms = [
// { id: 'douyin', name: '抖音', color: '#FE2C55' }
// ]
// const activePlatform = ref('douyin')
// 搜索关键词
const searchKeyword = ref('')
// 加载状态
const isLoading = ref(false)
// 热点列表数据(模拟数据)
const hotTopics = ref([])
// 选中的热点
const selectedTopic = ref(null)
// 右侧详细信息
const topicDetails = reactive({
title: '',
copywriting: '',
stylePrompt: ''
})
// 点击创作按钮
const handleCreate = (topic) => {
selectedTopic.value = topic.id
topicDetails.title = topic.title
topicDetails.copywriting = ''
topicDetails.stylePrompt = ''
}
// 立即生成
const handleGenerate = () => {
console.log('生成内容', topicDetails)
// TODO: 调用生成API
}
// 切换平台
// const switchPlatform = (platformId) => {
// activePlatform.value = platformId
// selectedTopic.value = null
// topicDetails.title = ''
// topicDetails.copywriting = ''
// topicDetails.stylePrompt = ''
// }
// 搜索热点
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
message.warning('请输入搜索关键词')
return
}
isLoading.value = true
try {
const response = await TikhubService.postTikHup(
{
type: InterfaceType.DOUYIN_SEARCH_GENERAL_SEARCH, // 使用网页端通用搜索结果接口
methodType: MethodType.POST,
urlParams: {
keyword: searchKeyword.value.trim(),
sort_type: '1',
offset: 0,
count: 20,
publish_time: 7
},
paramType: ParamType.JSON
}
)
// 处理搜索结果
const searchResults = response.data.data.map(el => el.aweme_info).filter(el => el).map((item, index) => ({
id: hotTopics.value.length + index + 1,
title: item.desc || '无标题',
videoId: item.aweme_id,
videoUrl: `https://www.douyin.com/video/${item.aweme_id}`, // 视频链接
author: item.author.nickname,
// 统计数据
playCount: item.statistics?.play_count || 0, // 播放量
diggCount: item.statistics?.digg_count || 0, // 点赞量(喜欢)
commentCount: item.statistics?.comment_count || 0, // 评论数
shareCount: item.statistics?.share_count || 0, // 分享数
collectCount: item.statistics?.collect_count || 0, // 收藏数
}))
// 将搜索结果添加到列表顶部
hotTopics.value = [...searchResults, ...hotTopics.value]
message.success(`找到 ${searchResults.length} 个结果`)
} catch (error) {
console.error('搜索失败:', error)
message.error(error?.message || '搜索失败,请稍后重试')
} finally {
isLoading.value = false
}
}
// 回车搜索
const handleSearchKeypress = (event) => {
if (event.key === 'Enter' && !isLoading.value) {
handleSearch()
}
}
// 格式化数字,将大数字转换为万等单位
const formatNumber = (num) => {
if (!num) return '0'
if (num >= 10000) {
return (num / 10000).toFixed(1) + 'w'
}
return num.toString()
}
// 打开视频
const openVideo = (topic, event) => {
event.stopPropagation() // 阻止事件冒泡,避免触发热点选择
if (topic.videoUrl) {
window.open(topic.videoUrl, '_blank')
}
}
// 截断标题,限制最大长度
const truncateTitle = (title, maxLength = 30) => {
if (!title) return ''
if (title.length <= maxLength) return title
return title.substring(0, maxLength) + '...'
}
</script>
<template>
<div class="fc-page">
<div class="fc-grid">
<!-- 左侧平台栏目和热点列表 -->
<section class="fc-left">
<div class="fc-title">热点预测</div>
<!-- 平台选择 -->
<!-- <div class="platform-tabs">
<div class="flex space-x-2">
<button
v-for="platform in platforms"
:key="platform.id"
@click="switchPlatform(platform.id)"
:class="['platform-tab', activePlatform === platform.id ? 'platform-tab--active' : 'platform-tab--inactive']"
:style="activePlatform === platform.id ? { background: platform.color } : {}"
>
{{ platform.name }}
</button>
</div>
</div> -->
<!-- 搜索框 -->
<div class="search-box">
<div class="search-input-wrapper">
<input v-model="searchKeyword" type="text" placeholder="输入关键词搜索抖音热点..." class="search-input"
:disabled="isLoading" @keypress="handleSearchKeypress" />
<button @click="handleSearch" :disabled="isLoading || !searchKeyword.trim()" class="search-btn"
:class="{ 'search-btn--loading': isLoading }">
<svg v-if="!isLoading" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<svg v-else class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
</button>
</div>
</div>
<!-- 热点列表 -->
<div class="topic-list">
<div class="p-2">
<!-- 空状态 -->
<div v-if="hotTopics.length === 0" class="empty-state">
<svg class="empty-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
<circle cx="9" cy="9" r="3"></circle>
</svg>
<p class="empty-text">搜索抖音热点内容</p>
<p class="empty-hint">输入关键词开始搜索热门内容</p>
</div>
<!-- 热点列表 -->
<div v-for="topic in hotTopics" :key="topic.id" @click="handleCreate(topic)" class="topic-item"
:class="{ 'topic-item--selected': selectedTopic === topic.id }">
<div class="flex flex-col flex-1 min-w-0">
<div class="flex items-center mb-2">
<span class="topic-number">{{ topic.id }}</span>
<span
v-if="topic.videoUrl"
@click="openVideo(topic, $event)"
class="flex-1 topic-title topic-title--clickable"
:title="topic.title"
>
{{ truncateTitle(topic.title) }}
</span>
<span v-else class="flex-1 topic-title" :title="topic.title">
{{ truncateTitle(topic.title) }}
</span>
</div>
<!-- 统计信息 -->
<div v-if="topic.diggCount || topic.playCount || topic.commentCount || topic.collectCount || topic.shareCount" class="flex flex-wrap items-center gap-4 text-xs text-gray-500 topic-stats">
<span v-if="topic.playCount" class="stat-item">
<svg class="inline w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" />
</svg>
播放 {{ formatNumber(topic.playCount) }}
</span>
<span v-if="topic.diggCount" class="stat-item">
<svg class="inline w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.834a1 1 0 001.555.832L12 13.202a5 5 0 001.196-.599l.707-.707a1 1 0 00.293-.707V8.465a1 1 0 00-1.707-.707L12 8.465V5a2 2 0 00-2-2H7a1 1 0 000 2h3v3z" />
</svg>
点赞 {{ formatNumber(topic.diggCount) }}
</span>
<span v-if="topic.commentCount" class="stat-item">
<svg class="inline w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z" clip-rule="evenodd" />
</svg>
评论 {{ formatNumber(topic.commentCount) }}
</span>
<span v-if="topic.collectCount" class="stat-item">
<svg class="inline w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd" />
</svg>
收藏 {{ formatNumber(topic.collectCount) }}
</span>
<span v-if="topic.shareCount" class="stat-item">
<svg class="inline w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M15 8a3 3 0 10-2.977-2.63l-4.94 2.47a3 3 0 100 4.319l4.94 2.47a3 3 0 10.895-1.789l-4.94-2.47a3.027 3.027 0 000-.74l4.94-2.47C13.456 7.68 14.19 8 15 8z" />
</svg>
分享 {{ formatNumber(topic.shareCount) }}
</span>
</div>
</div>
<button @click.stop="handleCreate(topic)" class="create-btn">
创作
</button>
</div>
</div>
</div>
</section>
<!-- 右侧详细信息 -->
<section class="fc-right">
<div class="fc-title">创作详情</div>
<div class="detail-content">
<!-- 热点标题 -->
<div>
<label class="form-label">热点标题</label>
<input v-model="topicDetails.title" type="text" placeholder="选择左侧热点或手动输入标题" class="form-input" />
</div>
<!-- 文案 -->
<div>
<label class="form-label">文案</label>
<textarea v-model="topicDetails.copywriting" rows="5" placeholder="输入或AI生成文案内容"
class="form-textarea"></textarea>
</div>
<!-- 风格提示词 -->
<div>
<label class="form-label">风格提示词</label>
<input v-model="topicDetails.stylePrompt" type="text" placeholder="例如:专业、权威、温暖、幽默等" class="form-input" />
</div>
<!-- 立即生成按钮 -->
<div class="pt-2">
<button @click="handleGenerate" class="generate-btn">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
clip-rule="evenodd" />
</svg>
立即生成
</button>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.fc-page {
color: var(--color-text);
}
.fc-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (min-width: 1024px) {
.fc-grid {
grid-template-columns: 1fr 1fr;
}
}
.fc-left,
.fc-right {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-inset-card);
padding: 16px;
}
.fc-title {
font-size: 14px;
color: var(--color-text-secondary);
margin-bottom: 8px;
}
/* 平台标签区域 */
.platform-tabs {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-border);
}
.platform-tab {
flex: 1;
padding: 8px 12px;
font-size: 14px;
font-weight: 500;
border-radius: 8px;
transition: all 0.2s;
}
.platform-tab--active {
color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.platform-tab--inactive {
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.platform-tab--inactive:hover {
background: var(--color-bg);
color: var(--color-text);
opacity: 0.8;
}
/* 搜索框 */
.search-box {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-border);
}
.search-input-wrapper {
display: flex;
gap: 8px;
}
.search-input {
flex: 1;
padding: 8px 12px;
font-size: 14px;
color: var(--color-text);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 6px;
transition: all 0.2s;
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(0, 176, 48, 0.1);
}
.search-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.search-input::placeholder {
color: var(--color-text-secondary);
opacity: 0.6;
}
.search-btn {
padding: 8px 16px;
background: var(--color-primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
min-width: 48px;
}
.search-btn:hover:not(:disabled) {
background: var(--color-primary);
filter: brightness(1.1);
transform: scale(1.05);
}
.search-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.search-btn--loading {
opacity: 0.8;
}
/* 热点列表 */
.topic-list {
max-height: 500px;
overflow-y: auto;
}
.topic-list::-webkit-scrollbar {
width: 6px;
}
.topic-list::-webkit-scrollbar-track {
background: var(--color-bg);
border-radius: 10px;
}
.topic-list::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 10px;
}
.topic-list::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.empty-icon {
width: 80px;
height: 80px;
color: var(--color-text-secondary);
opacity: 0.4;
margin-bottom: 16px;
}
.empty-text {
font-size: 16px;
font-weight: 500;
color: var(--color-text);
margin-bottom: 8px;
}
.empty-hint {
font-size: 14px;
color: var(--color-text-secondary);
opacity: 0.6;
}
/* 热点项 */
.topic-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 12px;
margin-bottom: 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
border: 1px solid transparent;
}
.topic-item:hover {
background: var(--color-bg);
}
.topic-item--selected {
background: var(--color-bg);
border-color: var(--color-primary);
}
.topic-number {
flex-shrink: 0;
margin-right: 12px;
font-size: 14px;
font-weight: 600;
color: var(--color-text-secondary);
}
.topic-title {
font-size: 14px;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.topic-title--clickable {
cursor: pointer;
color: #1890ff;
transition: all 0.2s;
}
.topic-title--clickable:hover {
text-decoration: underline;
color: #40a9ff;
opacity: 0.8;
}
/* 统计信息 */
.topic-stats {
padding-left: 32px;
margin-top: 4px;
}
.stat-item {
display: inline-flex;
align-items: center;
color: var(--color-text-secondary);
}
.stat-item svg {
width: 12px;
height: 12px;
vertical-align: middle;
}
/* 创作按钮 */
.create-btn {
margin-left: auto;
padding: 6px 16px;
font-size: 14px;
font-weight: 500;
color: white;
background: var(--color-primary);
border: none;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 176, 48, 0.2);
transition: all 0.2s;
flex-shrink: 0;
align-self: flex-start;
margin-top: 4px;
}
.create-btn:hover {
box-shadow: 0 0 12px rgba(0, 176, 48, 0.5);
filter: brightness(1.1);
transform: scale(1.05);
}
/* 详情内容 */
.detail-content {
color: var(--color-text);
}
.form-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: var(--color-text);
}
.form-input,
.form-textarea {
width: 100%;
padding: 8px 12px;
color: var(--color-text);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 6px;
transition: all 0.2s;
font-size: 14px;
font-family: inherit;
}
.form-input::placeholder,
.form-textarea::placeholder {
color: var(--color-text-secondary);
opacity: 0.6;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(0, 176, 48, 0.1);
}
.form-textarea {
resize: none;
}
.generate-btn {
width: 100%;
padding: 8px 24px;
background: var(--color-primary);
color: white;
font-weight: 600;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 176, 48, 0.3);
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.generate-btn:hover {
box-shadow: 0 0 12px rgba(0, 176, 48, 0.4);
filter: brightness(1.05);
transform: scale(1.01);
}
</style>

View File

@@ -0,0 +1,217 @@
<script setup>
import { ref } from 'vue'
// 定义组件名称
defineOptions({
name: 'HeatAnalysis'
})
const keywords = ref('')
const platform = ref('')
const timeWindow = ref('')
</script>
<template>
<div class="min-h-screen bg-gray-50 p-6">
<div class="max-w-7xl mx-auto space-y-6">
<!-- 页面标题 -->
<div class="text-center">
<h1 class="text-3xl font-bold text-gray-900 mb-2">热度趋势分析</h1>
<p class="text-gray-600">实时监控关键词在各大平台的热度变化</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 查询参数面板 -->
<section class="lg:col-span-1">
<div class="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
</svg>
查询设置
</h3>
</div>
<div class="space-y-4">
<!-- 关键词输入 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">关键词</label>
<div class="relative">
<input
v-model="keywords"
class="w-full border border-gray-300 rounded-lg px-4 py-3 text-gray-900 placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200 bg-gray-50 hover:bg-white"
placeholder="输入关键词,多个用逗号分隔"
type="text"
/>
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
</div>
<p class="text-xs text-gray-500 mt-1">支持多个关键词用逗号分隔</p>
</div>
<!-- 平台和时间选择 -->
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">平台选择</label>
<select
v-model="platform"
class="w-full border border-gray-300 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200 bg-gray-50 hover:bg-white"
>
<option value="">请选择平台</option>
<option value="weibo">微博</option>
<option value="douyin">抖音</option>
<option value="xiaohongshu">小红书</option>
<option value="zhihu">知乎</option>
<option value="toutiao">今日头条</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">时间窗口</label>
<select
v-model="timeWindow"
class="w-full border border-gray-300 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 Focus:ring-purple-500 focus:border-transparent transition-all duration-200 bg-gray-50 hover:bg-white"
>
<option value="">请选择时间范围</option>
<option value="1h">最近1小时</option>
<option value="6h">最近6小时</option>
<option value="24h">最近24小时</option>
<option value="7d">最近7天</option>
<option value="30d">最近30天</option>
</select>
</div>
</div>
<!-- 查询按钮 -->
<button class="w-full bg-gradient-to-r from-purple-600 to-purple-700 hover:from-purple-700 hover:to-purple-800 text-white font-semibold py-3 px-6 rounded-lg shadow-md hover:shadow-lg transform hover:scale-[1.02] transition-all duration-200 flex items-center justify-center">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
</svg>
开始分析
</button>
</div>
</div>
</section>
<!-- 分析结果面板 -->
<section class="lg:col-span-2">
<div class="bg-white rounded-xl shadow-lg border border-gray-100">
<!-- 看板头部 -->
<div class="px-6 py-4 border-b border-gray-100">
<h3 class="text-lg font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z" />
</svg>
热度趋势看板
</h3>
</div>
<!-- 图表内容区域 -->
<div class="p-6">
<!-- 占位内容 -->
<div class="flex items-center justify-center h-96 border-2 border-dashed border-gray-300 rounded-lg bg-gray-50">
<div class="text-center">
<svg class="w-16 h-16 text-gray-400 mx-auto mb-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 011 1v16a1 1 0 01-1 1h-3a1 1 0 01-1-1v-6a1 1 0 00-1-1H9a1 1 0 00-1 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1V4zm2 1v14h2v-6a1 1 0 011-1h4a1 1 0 011 1v6h2V5H5z" clip-rule="evenodd" />
</svg>
<p class="text-lg font-medium text-gray-900 mb-2">正在准备数据...</p>
<p class="text-gray-600">请先设置查询参数然后点击"开始分析"查看趋势图表</p>
<div class="mt-4 space-y-2">
<p class="text-sm text-gray-500">📈 热度折线图</p>
<p class="text-sm text-gray-500">📊 相关话题分布</p>
<p class="text-sm text-gray-500">🏆 热门排行榜</p>
</div>
</div>
</div>
<!-- 功能特性提示 -->
<div class="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="text-center p-4 bg-purple-50 rounded-lg">
<div class="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-4 h-4 text-purple-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z" />
</svg>
</div>
<h4 class="font-medium text-gray-900">趋势分析</h4>
<p class="text-xs text-gray-600">实时跟踪关键词热度变化</p>
</div>
<div class="text-center p-4 bg-blue-50 rounded-lg">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-4 h-4 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
<path fill-rule="evenodd" d="M4 5a2 2 0 012-2v1a1 1 0 102 0V3a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3z" clip-rule="evenodd" />
</svg>
</div>
<h4 class="font-medium text-gray-900">智能预测</h4>
<p class="text-xs text-gray-600">AI预测未来热度走势</p>
</div>
<div class="text-center p-4 bg-green-50 rounded-lg">
<div class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 011 1v.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 012 17.5V17zm1.5-2A1.5 1.5 0 003 16.5v.5h14v-.5a1.5 1.5 0 00-1.5-1.5h-11z" clip-rule="evenodd" />
</svg>
</div>
<h4 class="font-medium text-gray-900">多维分析</h4>
<p class="text-xs text-gray-600">跨平台数据对比分析</p>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
</template>
<style scoped>
/* 自定义滚动条样式 */
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 10px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 平滑过渡效果 */
.fade-in {
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 按钮悬停效果增强 */
button:hover {
box-shadow: 0 10px 25px -2px rgba(147, 51, 234, 0.5);
}
/* 输入框焦点效果 */
input:focus, select:focus {
box-shadow: 0 0 0 3px rgba(147, 51, 234, 0.1);
}
</style>

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,45 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@gold/*": ["../../*"],
"@/types/*": ["./src/types/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"src/**/*.d.ts",
"../hooks/**/*.ts",
"../hooks/**/*.tsx"
],
"exclude": [
"node_modules",
"dist"
],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.js"]
}

View File

@@ -0,0 +1,87 @@
/* eslint-env node */
import path from 'node:path'
import { defineConfig, loadEnv } from 'vite'
import { fileURLToPath, URL } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
import process from 'node:process'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import UnoCSS from 'unocss/vite'
// import electron from 'vite-plugin-electron/simple'
/**
* Vite 配置文件
* 支持 TypeScript 和 JavaScript
* @param {Object} param0 - 配置参数
* @param {string} param0.mode - 环境模式
* @returns {import('vite').UserConfig}
*/
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'
return {
plugins: [
vue(),
vueJsx(),
UnoCSS(),
vueDevTools(),
tailwindcss()
// electron({
// main: {
// // Shortcut of `build.lib.entry`.
// entry: 'electron/main.ts',
// vite: {
// build: {
// rollupOptions: {
// external: ['better-sqlite3'],
// },
// },
// },
// },
// preload: {
// input: fileURLToPath(new URL('./electron/preload.ts', import.meta.url)),
// },
// // See 👉 https://github.com/electron-vite/vite-plugin-electron-renderer
// renderer:
// process.env.NODE_ENV === 'test'
// ? // https://github.com/electron-vite/vite-plugin-electron-renderer/issues/78#issuecomment-2053600808
// undefined
// : {},
// }),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@gold': fileURLToPath(new URL('../../', import.meta.url))
},
},
server: {
proxy: {
'/webApi': {
target: API_TARGET,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/webApi/, ''),
configure: (proxy, options) => {
proxy.on('proxyReq', (proxyReq, req) => {
// 从客户端请求头中获取 authorization
const authHeader = req.headers?.authorization
if (authHeader) {
proxyReq.setHeader('authorization', authHeader)
} else if (DEV_TOKEN) {
// 兜底:使用环境变量中的 token
proxyReq.setHeader('authorization', `Bearer ${DEV_TOKEN}`)
}
// 添加 RuoYi 租户 ID
proxyReq.setHeader('tenant-id', TENANT_ID)
})
},
},
},
},
}
})