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

30
.gitignore vendored
View File

@@ -45,6 +45,7 @@ nbdist/
!*/build/*.html
!*/build/*.xml
### JRebel ###
rebel.xml
@@ -52,3 +53,32 @@ application-my.yaml
/yudao-ui-app/unpackage/
**/.DS_Store
node_modules
.vscode-test/
*.vsix
.idea
pnpm-lock.yaml
.clineignore
# Ignore coverage directories and files
coverage
coverage-unit
.nyc_output
# But don't ignore the coverage scripts in .github/scripts/
!.github/scripts/coverage/
*evals.env
# E2E Tests
test-results
## CLI pre-release ##
/cli

View File

@@ -0,0 +1,63 @@
# 胶卷风格AI工具设计规范二次创适用版
## **核心风格定位**
「复古胶片暗调+现代代工具极简感」,以黑色基底为核心,叠加入胶片颗粒肌理,整体视觉克制而有质感,突出「剪辑工具的专业感」与「胶卷复古的氛围感」,避免与现有工具同质化。
## **1. 颜色规范**
- **主色**
- 背景:#0D0D0D深黑带1%青灰调,区别纯黑)
- 主功能色:#00B030(低饱和苔藓绿,用于按钮/选中态,与已知品牌色差异明显)
- **辅助色**
- 交互蓝:#1A66E0(用于预览/保存等次级操作)
- 强调橙:#FF6A30(用于标记点/警告,低明度避免刺眼)
- **中性色**
- 模块底:#1A1A1A比背景亮5%,区分层级)
- 文本:#F2F2F2(正文)、#CCCCCC(次要文本)
- 边框:#3333331px细线条弱化割裂感
## **2. 质感与阴影**
- **肌理**全局叠加原创胶片颗粒3%灰度噪点,随机生成,非真实胶卷扫描图)
- **阴影**
- 卡片/模块内阴影0 2px 4px rgba(0,0,0,0.4)),无外阴影
- 按钮hover轻微发光0 0 6px rgba(0,176,48,0.3),主色低饱和光晕)
## **3. 图标选型**
- **风格**线性几何风线条粗细1.5px圆角2px
- **载体**:统一使用 SVG图标文件与 SVG Sprite/Icon 组件),禁止使用位图作为图标
- **禁用**避免使用与知名剪辑工具高度相似的图标如剪映、Pr的标志性符号
## **4. 卡片规范**
- **形态**圆角6px非直角/大圆角边框1px #333333
- **内容区**内边距16px底部可加「胶片式参数条」黑底白字小文本如“1080p | 30fps”纯装饰
- **状态**
- 活跃态:边框改为主色#00B030
- hover态背景色加深至#161616
## **5. 布局规范**
- **整体结构**顶部导航高52px+ 左侧功能栏宽60px图标/200px展开+ 主内容区占比70%+ 右侧参数面板占比30%
- **间距**模块间margin 20px元素内padding 12-16px避免拥挤
- **移动端**左侧栏转为底部悬浮按钮组4个核心功能+居中主按钮)
## **6. 标题与文本**
- **标题**字体「Montserrat」半粗体20px字间距0.5px,颜色#F2F2F2
- **正文**字体「Inter」常规14px行高1.5,颜色#F2F2F2
- **辅助文本**字体「Inter」常规12px颜色#CCCCCC
## **7. 设计提示词(供生成式设计/插画参考)**
- **总体风格**:复古胶片暗调、现代极简 UI、低饱和高级质感、专业剪辑工具氛围、控色节制
- **质感**微颗粒胶片噪点3% 灰度随机)、内阴影层次、金属磨砂、磨光边缘
- **配色**:深黑 #0D0D0D 背景、模块底 #1A1A1A、主色 #00B030、交互蓝 #1A66E0、强调橙 #FF6A30、细边界 #333333
- **光影**:按钮 Hover 轻微发光0 0 6px rgba(0,176,48,0.3)、卡片内阴影inset 0 2px 4px rgba(0,0,0,0.4)
- **形态**:圆角 6px、1px 细边、紧凑留白(内边距 1216px模块间距 20px
- **图标**线性几何、1.5px 描边、统一 SVG、避免品牌相似符号
- **插画/装饰**:暗色渐变+噪点、胶片孔洞/标尺式细节可点缀,勿喧宾夺主
- **可用性**:高对比可读性、色弱可访问、交互状态清晰(禁用/加载/选中)

View File

@@ -0,0 +1,87 @@
---
description: 现代 Web 应用中的 Vue.js 最佳实践与模式
globs: **/*.vue, **/*.ts, components/**/*
---
# Vue.js 最佳实践
## 组件结构
- 优先使用组合式 API 而非选项式 API
- 保持组件小巧且功能专注
- 采用恰当的 TypeScript 集成方案
- 实现规范的 props 验证
- 使用标准的 emit 声明
- 保持模板逻辑简洁
- 优先使用template 语法,而不是函数组件
## 组合式 API
- 正确使用 ref 与 reactive
- 合理实现生命周期钩子
- 通过组合式函数封装可复用逻辑
- 保持 setup 函数整洁
- 规范使用计算属性
- 合理实现侦听器
## 状态管理
- 使用 Pinia 进行状态管理
- 保持仓库模块化
- 采用合理的状态组织方式
- 规范实现操作逻辑
- 正确使用获取器
- 妥善处理异步状态
## 性能优化
- 实现组件懒加载
- 配置恰当的缓存策略
- 高效使用计算属性
- 避免不必要的侦听器
- 区分使用 v-show 与 v-if
- 实现科学的 key 管理
## 路由管理
- 规范使用 Vue Router
- 实现完整的导航守卫
- 合理配置路由元字段
- 正确处理路由参数
- 实现路由懒加载
- 使用标准的导航方法
## 表单处理
- 正确使用 v-model
- 实现完善的验证机制
- 规范处理表单提交
- 展示合理的加载状态
- 配置完整的错误处理
- 实现表单重置功能
## TypeScript 集成
- 使用规范的组件类型定义
- 实现完整的 props 类型声明
- 规范 emit 类型声明
- 处理类型推断
- 使用标准的组合函数类型
- 实现完整的仓库类型定义
## 测试策略
- 编写规范的单元测试
- 实现完整的组件测试
- 正确使用 Vue Test Utils
- 全面测试组合式函数
- 实现科学的模拟机制
- 测试异步操作流程
## 开发规范
- 遵循 Vue 样式指南
- 使用统一的命名约定
- 保持组件结构清晰
- 实现完整的错误处理
- 规范事件处理机制
- 为复杂逻辑添加文档注释
## 构建与工具链
- 使用 Vite 进行开发
- 配置完整的构建方案
- 规范使用环境变量
- 实现代码分割方案
- 正确处理静态资源
- 配置完整的优化策略

28
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
dist
node_modules
.vscode-test/
*.vsix
.DS_Store
.idea
pnpm-lock.yaml
.clineignore
# Ignore coverage directories and files
coverage
coverage-unit
.nyc_output
# But don't ignore the coverage scripts in .github/scripts/
!.github/scripts/coverage/
*evals.env
# E2E Tests
test-results
## CLI pre-release ##
/cli

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)
})
},
},
},
},
}
})

View File

@@ -0,0 +1,24 @@
const config = {
/**
* api请求基础路径
*/
base_url: import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL,
/**
* 接口成功返回状态码
*/
result_code: 200,
/**
* 接口请求超时时间
*/
request_timeout: 30000,
/**
* 默认接口请求类型
* 可选值application/x-www-form-urlencoded multipart/form-data
*/
default_headers: 'application/json'
}
export { config }

146
frontend/doc/readme.md Normal file
View File

@@ -0,0 +1,146 @@
## 金牌内容大师软件页面结构
### 整体布局
- 顶部导航(深灰):左侧 Logo紫底白“逗”字 + 文案),右侧通知铃铛(红点数)、用户头像、主题切换
- 一级模块导航(横向 Tab/菜单):
- 内容风格分析(含 对标分析|文案创作)
- 热点趋势分析(含 热度分析|热点预测|文案创作)
- 数字人(含 声音训练|生成配音|生成数字人)
- 实时热点推送
- 素材混剪
- 剪映导入
- 购买会员优惠标签推广赚钱HOT 标签)|帮助|下载
- 主内容区(白色背景):按选中模块展示对应子模块页面
- 底部信息可选版本号、API 状态、版权提示
### 模块与子模块页面结构
#### 模块一:内容风格分析
1) 子模块:对标分析
- 顶部:标题“对标分析” + 简介 + 帮助入口
- 操作面板(左侧卡片):
- 链接输入区:平台选择单选(抖音|小红书)+ 主页/视频链接输入框
- 筛选项:点赞/收藏/评论范围、多选标签、时间范围、数量上限
- 执行按钮:开始分析(主色)、清空、示例一键填充
- 结果区(右侧/下方):
- 概览卡片:账号画像、样本量、时间窗口
- 结构分析卡片:标题结构要素、镜头/段落结构、开场/转场/收束模式
- 关键词统计:词云 + 频次表格
- 情绪倾向:柱状/雷达图(正/负/中性 + 情绪强度)
- 文本质量检测:错别字、语法、可读性评分
- 导出区:生成 Word 报告(含目录/图表/表格)、复制结论摘要
- 扩展卡片:风格特征提示词(结构模式、表达方式、比喻/设问等)
2) 子模块:文案创作
- 顶部:标题“文案创作” + 风格来源选择(对标分析结果/手动配置)
- 左侧:创作参数卡片
- 主题/选题输入、受众画像、平台体裁(短视频/图文)、字数与段落数
- 风格特征(从对标分析继承或手动选择):语气、句式密度、金句频率、节奏
- 约束:禁用词、合规提醒、敏感词检测
- 生成按钮:生成文案(可多样本)
- 右侧:结果编辑器
- 多样本标签页切换、评分与重写、段落级润色与扩写
- 结构优化建议面板共享逻辑标题建议、开头抓取点、转场优化、CTA 变体
- 导出:复制、保存为草稿、导出 Markdown/Word
#### 模块二:热点趋势分析
1) 子模块:热度分析
- 顶部:标题“热度分析” + 数据源指示(官方 API第三方
- 左侧:关键词与订阅
- 关键词输入(支持多个),平台选择,时间窗口,频率(实时/每日)
- 订阅开关:关注话题变化通知
- 查询按钮
- 右侧:趋势看板
- 热度折线/面积图(支持对比多个关键词)
- 相关话题分布(词云/桑基/气泡图)
- 榜单列表(热度值、涨跌幅、来源、时间)
- 个性化推荐(可选):基于历史偏好推荐相近话题
- 竞品分析(可选):同类内容的表现对比与优化方向
- 导出:趋势报告 PDF/图片、订阅保存
2) 子模块:热点预测
- 顶部:标题“热点预测” + 更新频率 + 最近同步时间
- 左侧:数据设置
- 平台榜单选择、历史维度、预测周期、预测方法(可选)
- 执行按钮:计算预测
- 右侧:预测结果
- 趋势外推曲线与置信区间
- 上升/下降话题榜卡片
- 风险提示与置信度标注
- 原创建议(核心扩展):基于趋势给出选题与角度建议,一键跳转“文案创作”
3) 子模块:文案创作(与模块一共享逻辑)
- 入口参数预填:来自热度分析/热点预测的关键词与角度
- 结果编辑器同模块一,增加“热点引用标注”与“数据出处注记”
#### 模块三:数字人
1) 子模块:声音训练
- 左侧:样本上传
- 上传语音样本(数量/时长提示)、采样率与格式要求、合法性合规弹窗
- 训练参数:噪声抑制、音域范围、情感维度
- 提交训练按钮
- 右侧:训练进度与质量
- 进度条、阶段日志、预计完成时间
- 质量检测结果:信噪比、清晰度、稳定性评分
- 优化建议卡片
2) 子模块:生成配音
- 左侧:文本输入与语气控制
- 文案输入(或从“文案创作”选择)、说话速度、停连、情感标签
- 说话人(训练好的声线列表)、发音词典(可选)
- 生成按钮 + 批量生成
- 右侧:音频预览与管理
- 播放、片段试听、对比 A/B、噪声/呼吸/口型对齐提示
- 质量检测报告(可选):节奏、情感一致性、清晰度
- 下载mp3/wav与导出到“剪映导入”
3) 子模块:生成数字人
- 左侧:配置
- 数字人形象选择、背景与模板、脚本来源(文案 + 配音)
- 表情与动作控制(关键帧/预设)、唇形同步开关
- 分辨率、时长、字幕选项
- 生成按钮
- 右侧:视频预览与任务队列
- 渲染进度、日志、错误重试
- 导出mp4mov与推送至“剪映导入”
#### 模块四:实时热点推送
- 榜单看板:平台切换、实时/小时/日视图
- 订阅管理:新增/编辑/删除、通知频率、通知方式
- 趋势联动:一键进入“热点预测”分析
#### 模块五:素材混剪
- 左侧:文案拆解
- 从“文案创作”选择文案,自动分段(镜头/语义)
- 段落卡片:时长建议、情绪标签、镜头类型
- 右侧:素材匹配与时间线
- 素材库搜索(标签/颜色/场景/人物AI 推荐位
- 画布与时间线:轨道(视频/音频/字幕),吸附与对齐
- 一键组合:按模版快速生成初版
- 导出到“剪映导入”
#### 模块六:剪映导入
- 导入项选择:文案、字幕文件、配音音频、数字人视频、混剪工程
- 格式兼容提示:编码、帧率、分辨率、自适配策略
- 一键导入:生成剪映工程文件/素材包,打开路径/直接启动剪映(可选)
### 设计要点(模块化增强)
- 色彩:紫色主色、深灰导航、白色内容区;状态色区分任务与错误
- 图标:模块与子模块使用语义明确图标,功能与结果分区清晰
- 交互:双栏布局优先(左操作右结果/预览),异步任务显著进度提示
- 信息层次:概览 > 关键图表/卡片 > 详细表格/日志
- 模块联动:子模块间保留“送往/引入”操作(如趋势 → 文案、文案 → 配音/数字人、生成项 → 剪映)
- 合规与提示:上传/生成环节均有合规提醒与风险提示,不展示原始受版权保护内容
### 技术实现提示词
- 前端Vue.js + Vite + Tailwind CSS组件化导航、表格、图表、上传、时间线
- 状态管理:用户信息、通知数量、主题、任务队列、订阅配置
- 图表:折线/面积/柱状/雷达/词云ECharts 或 Chart.js
- 文件:音视频上传与转码进度、任务轮询、失败重试
- API 接口:模块化设计,参数入参与结构化响应,跨模块数据传递(如选题、风格特征、音视频产物)
- 导出Word/PDF 报告、媒体文件、剪映工程/素材包
### 工作流快捷入口(横向步骤条)
- 对标分析 → 文案创作 → 生成配音/数字人 → 素材混剪 → 剪映导入
- 每步显示完成状态与产物摘要,支持回溯与替换

View File

@@ -0,0 +1,187 @@
# 金牌内容大师软件需求文档(合规版)
## 1. 项目概述
### 1.1 项目背景
随着社交媒体平台的快速发展,内容创作已成为品牌营销和个人影响力建设的核心环节。为提升内容创作者的效率和质量,降低创作门槛,我们计划开发一款集内容分析、热点追踪、语音生成、数字人视频制作及素材管理于一体的综合性软件工具。
### 1.2 项目目标
- 开发一款可安装在电脑上的金牌内容大师软件
- 实现内容风格分析、原创内容生成、热点趋势分析等核心功能
- 集成语音生成、数字人、素材混剪及剪映导入功能
- 提供高效、稳定、易用的内容创作辅助工具
### 1.3 适用范围
本软件适用于内容创作者、营销人员、自媒体博主等需要进行社交媒体内容创作和分析的用户支持Windows和MacOS操作系统。
### 1.4 法律声明
**重要提示**:本软件仅用于内容创作辅助和数据分析,用户需确保所提供内容的合法性。软件不承担任何版权责任,用户使用本软件产生的任何法律后果由用户自行承担。
## 2. 核心功能需求
### 2.1 功能一:内容风格分析
#### 2.1.1 核心功能
- **链接内容解析**:用户提供博主主页或单条视频链接,系统进行内容结构分析
- 支持抖音、小红书平台链接解析
- 可选择点赞数、收藏数、评论数等关键变量进行筛选
- 输出内容结构分析报告,而非原始文案内容
- **内容特征提取**:分析视频内容的标题结构、关键词分布、情绪倾向
- **Word报告输出**:包含内容结构分析、关键词统计、情绪分析等表格信息
- **文本质量检测**:对用户上传的文本内容进行错别字检测和语法优化建议
#### 2.1.2 可选择功能
- **风格特征生成**:基于分析结果生成内容风格特征描述、结构模式、表达方式等提示词
- **原创内容生成**:基于用户提供的主题和风格特征,生成原创文案内容
- **内容优化建议**:为用户提供内容结构优化和表达方式改进建议
### 2.2 功能二:热点趋势分析
#### 2.2.1 核心功能
- **热点关键词检索**:输入热点选题关键词,检索公开的热点趋势数据
- 支持从官方API获取热点数据
- 支持用户订阅特定话题的趋势变化
- **趋势分析报告**:生成热点趋势分析报告,包含关键词热度变化、相关话题分布等
- **原创内容生成**:基于热点趋势和用户需求,生成原创内容建议
#### 2.2.2 可选择功能
- **个性化推荐**:根据用户历史偏好推荐相关热点话题
- **竞品分析**:分析同类内容的表现趋势和优化方向
### 2.3 功能三:实时热点推送
- **热点数据更新**通过官方API或第三方数据服务获取热点榜单数据
- **热点展示**:以直观的方式展示各平台热点内容及相关数据
- **趋势预测**:基于历史数据预测热点发展趋势
### 2.4 功能四:语音生成
- **声线模拟**:用户上传语音样本,系统学习声线特征并生成相似声线的语音
- **丰富语气**:生成的语音支持丰富的语气表达,符合语境要求
- **语音优化**:提供语音质量检测和优化建议
### 2.5 功能五:数字人
- **数字人调用**:可调用训练好的数字人模型
- **数字人视频生成**:基于选定的数字人和内容,生成对应的数字人视频
- **表情控制**:支持数字人表情和动作的精细控制
### 2.6 功能六:素材混剪
- **文案拆解与素材匹配**:拆解文案内容,为对应的文案部分选择合适的素材
- **素材组合**:组合所有选定的素材,生成完整的视频内容
- **智能推荐**:基于内容特征智能推荐合适的素材
### 2.7 功能七:剪映导入
- **无缝导入**:将上述功能生成的内容(文案、语音、数字人视频、混剪素材等)导入到剪映软件中进行进一步编辑
- **格式兼容**:确保导入的内容与剪映软件格式兼容,减少用户手动调整
### 2.8 功能八:用户与账号
#### 2.8.1 核心功能
- **微信扫码登录**:支持通过微信二维码进行登录授权
- 扫码状态轮询与超时处理
- 登录成功后建立用户会话Token/Cookie
- **头像入口**:登录后在页面右上角显示用户微信头像,点击进入个人中心
- **个人中心**:展示并管理用户信息与偏好配置
- 账号信息:昵称、头像、绑定手机号(可选)
-
#### 2.8.2 可选择功能
- **多端同步**:支持同账号多设备登录的会话管理与踢下线机制
- **通知中心**:登录与安全异常提醒、热点订阅提醒
- **企业微信/扫码登录扩展**:支持企业微信、飞书等扫码登录扩展
#### 2.8.3 合规与安全
- 严格遵循微信开放平台与微信扫码登录接口规范,仅获取最小必要的用户信息
- 明确授权用途并提供解除绑定入口,不保留敏感信息明文
- 登录与个人数据传输全程 HTTPS服务端存储敏感信息脱敏/加密
## 3. 系统架构
### 3.1 整体架构
采用模块化设计,各功能模块相对独立又相互协作,主要包括:
- 数据解析模块:负责解析用户提供的链接和内容
- 数据分析模块:对解析的数据进行分析,提取关键信息和特征
- 内容生成模块:基于分析结果生成原创文案、语音、数字人视频等内容
- 素材管理模块:管理用户上传和系统生成的各类素材
- 用户界面模块:提供友好的用户交互界面
- 外部接口模块:与剪映等外部软件进行数据交互
### 3.2 技术选型
- **前端技术**Vue3、Vite、Tailwind CSS、Ant Design Vue桌面端采用 Electron 封装),确保跨平台兼容性与良好用户体验
- **后端技术** java/ruoyi-vue-pro处理数据采集、分析和内容生成逻辑
- **数据库**MySQL存储用户数据、配置信息和临时文件
- **AI模型**NLP模型用于文案分析和生成、语音合成模型用于语音生成、计算机视觉模型用于数字人视频生成
- **第三方API**各社交平台开放API、语音合成API、剪映接口等
## 4. 技术要求
### 4.2 安全要求
- **数据安全**:用户数据加密存储,防止数据泄露和滥用
- **隐私保护**:严格遵守用户隐私政策,未经授权不得收集和使用用户个人信息
- **权限管理**:合理设置软件权限,避免越权操作
- **内容合规**:不存储原始内容,只保留分析结果和生成内容
### 4.3 兼容性要求
- **操作系统**支持Windows 10/11及MacOS 12+操作系统
- **软件兼容**与主流浏览器Chrome、Firefox、Safari和剪映等常用视频编辑软件兼容
- **分辨率**支持1080p及以上分辨率显示器
## 5. 用户界面要求
- **简洁直观**:界面设计简洁明了,操作流程直观易懂
- **功能分区**:各功能模块清晰分区,便于用户快速找到所需功能
- **交互友好**:提供丰富的交互提示和帮助信息,减少用户学习成本
- **自定义配置**:支持用户根据个人习惯自定义界面布局和功能设置
- **多语言支持**:支持中文简体和英文界面切换
## 6. 法律合规要求
### 6.1 用户协议
- **内容合法性**:用户需确保所提供内容的合法性,不得上传侵权内容
- **使用目的**:软件仅用于内容创作辅助和数据分析,不得用于商业侵权
- **免责声明**:软件不承担任何版权责任,用户使用产生的法律后果由用户自行承担
### 6.2 数据处理规范
- **数据最小化**:只收集和处理必要的数据
- **数据安全**:采用加密技术保护用户数据
- **数据删除**:用户可随时删除个人数据
- **数据共享**:不向第三方分享用户数据
### 6.3 内容生成规范
- **原创性保证**:生成的内容基于算法和模型,不直接复制他人作品
- **风格学习**:通过分析内容特征学习风格,而非复制具体表达
- **用户责任**:用户对生成内容的使用承担全部责任
## 7. 培训与支持
- **用户手册**:提供详细的用户操作手册,包含功能介绍、操作步骤和常见问题解答
- **技术支持**提供7*12小时技术支持服务及时解决用户遇到的问题
- **定期升级**定期发布软件更新修复bug并优化功能体验
## 8. 实施计划
- **需求确认**1周明确用户需求和功能细节
- **设计阶段**2周完成系统架构设计和界面原型设计
- **开发实现**8周完成软件核心功能开发和模块集成
- **测试验收**2周进行全面的功能测试和性能测试
- **上线部署**1周发布正式版本并提供用户下载安装
## 9. 附录
### 9.1 术语定义
- **金牌内容大师**:本软件的正式名称,指一款集内容分析、生成、管理于一体的综合性内容创作辅助工具
- **内容风格分析**:通过分析内容特征提取风格特征,而非复制具体内容
- **热点趋势分析**:基于公开数据分析热点话题的发展趋势
- **数字人**:通过计算机技术生成的具有人类外观和行为特征的虚拟形象
- **素材混剪**:将多个视频、音频、图片等素材按照一定的逻辑和节奏进行组合编辑
### 9.2 参考文档
- 各社交平台开放平台开发者文档
- 自然语言处理技术白皮书
- 语音合成技术规范
- 计算机视觉与数字人技术指南
- 剪映软件接口文档
- 数据保护法规和版权法相关文档
### 9.3 风险控制措施
- **技术措施**:采用内容特征提取而非内容复制
- **法律措施**:完善的用户协议和免责声明
- **管理措施**:严格的数据处理和内容生成规范
- **监控措施**:实时监控用户行为,防止违规使用

View File

@@ -0,0 +1,39 @@
/**
* 配置浏览器本地存储的方式,可直接存储对象数组。
*/
import WebStorageCache from 'web-storage-cache'
export const CACHE_KEY = {
// 用户相关
ROLE_ROUTERS: 'roleRouters',
USER: 'user',
VisitTenantId: 'visitTenantId',
// 系统设置
IS_DARK: 'isDark',
LANG: 'lang',
THEME: 'theme',
LAYOUT: 'layout',
DICT_CACHE: 'dictCache',
// 登录表单
LoginForm: 'loginForm',
TenantId: 'tenantId'
}
export const useCache = (type ='localStorage') => {
const wsCache = new WebStorageCache({
storage: type
})
return {
wsCache
}
}
export const deleteUserCache = () => {
const { wsCache } = useCache()
wsCache.delete(CACHE_KEY.USER)
wsCache.delete(CACHE_KEY.ROLE_ROUTERS)
wsCache.delete(CACHE_KEY.VisitTenantId)
// 注意,不要清理 LoginForm 登录表单
}

View File

@@ -0,0 +1,87 @@
import CommonService from '@/api/common'
import type { AudioItem, TranscriptionResult } from '@/src/types/global'
/**
* 转录数据接口
*/
interface TranscriptionData {
file_url?: string
transcripts?: Array<{ text: string }>
}
/**
* 响应结果接口
*/
interface TranscriptionResponse {
results: Array<{
transcription_url: string
}>
}
/**
* 将音频列表转换为文本转录
* @param list - 音频项列表
* @returns 转录结果数组
* @throws 当转录过程出错时抛出错误
*
* @example
* const audioList = [{ audio_url: 'https://example.com/audio.mp3' }]
* const transcriptions = await getVoiceText(audioList)
* console.log(transcriptions) // [{ key: 'url', value: 'transcribed text' }]
*/
export async function getVoiceText(list: AudioItem[]): Promise<TranscriptionResult[]> {
// 调用API将视频转换为文本
const ret = await CommonService.videoToCharacters({
fileLinkList: list.map(item => item.audio_url),
})
// 解析响应数据
const data: string = ret.data
const rst: TranscriptionResponse = JSON.parse(data)
const transcription_url: string[] = rst.results.map(item => item.transcription_url)
// 并行获取所有转录内容
const transcriptions: TranscriptionResult[] = await Promise.all(
(transcription_url || []).filter(Boolean).map(async (url: string): Promise<TranscriptionResult> => {
try {
const resp: Response = await fetch(url)
const contentType: string = resp.headers.get('content-type') || ''
const value: string = contentType.includes('application/json')
? JSON.stringify(await resp.json())
: await resp.text()
const parsed: TranscriptionData = JSON.parse(value)
return {
key: url,
audio_url: parsed.file_url,
value: parsed.transcripts?.[0]?.text || ''
}
} catch (e: unknown) {
console.warn('获取转写内容失败:', url, e)
return { key: url, value: '' }
}
})
)
return transcriptions
}
/**
* Hook 返回值接口
*/
interface UseVoiceTextReturn {
getVoiceText: (list: AudioItem[]) => Promise<TranscriptionResult[]>
}
/**
* 语音文本转换 Hook
* @returns 包含 getVoiceText 方法的对象
*
* @example
* const { getVoiceText } = useVoiceText()
* const result = await getVoiceText(audioList)
*/
export default function useVoiceText(): UseVoiceTextReturn {
return { getVoiceText }
}

18
frontend/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "gold-master",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"gold": "pnpm --filter ./app/web-gold run dev",
"build:gold:prod": "pnpm --filter ./app/web-gold run build"
},
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"localforage": "^1.10.0",
"unocss": "^66.5.4",
"web-storage-cache": "^1.1.1"
}
}

View File

@@ -0,0 +1,7 @@
packages:
- build
- utils
# - packages/*
- app/*
catalog: