前端
This commit is contained in:
30
.gitignore
vendored
30
.gitignore
vendored
@@ -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
|
||||
|
||||
63
frontend/.cursorules/design.md
Normal file
63
frontend/.cursorules/design.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 胶卷风格AI工具设计规范(二次创适用版)
|
||||
|
||||
|
||||
## **核心风格定位**
|
||||
「复古胶片暗调+现代代工具极简感」,以黑色基底为核心,叠加入胶片颗粒肌理,整体视觉克制而有质感,突出「剪辑工具的专业感」与「胶卷复古的氛围感」,避免与现有工具同质化。
|
||||
|
||||
|
||||
## **1. 颜色规范**
|
||||
- **主色**:
|
||||
- 背景:#0D0D0D(深黑,带1%青灰调,区别纯黑)
|
||||
- 主功能色:#00B030(低饱和苔藓绿,用于按钮/选中态,与已知品牌色差异明显)
|
||||
- **辅助色**:
|
||||
- 交互蓝:#1A66E0(用于预览/保存等次级操作)
|
||||
- 强调橙:#FF6A30(用于标记点/警告,低明度避免刺眼)
|
||||
- **中性色**:
|
||||
- 模块底:#1A1A1A(比背景亮5%,区分层级)
|
||||
- 文本:#F2F2F2(正文)、#CCCCCC(次要文本)
|
||||
- 边框:#333333(1px细线条,弱化割裂感)
|
||||
|
||||
|
||||
## **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 细边、紧凑留白(内边距 12–16px,模块间距 20px)
|
||||
- **图标**:线性几何、1.5px 描边、统一 SVG、避免品牌相似符号
|
||||
- **插画/装饰**:暗色渐变+噪点、胶片孔洞/标尺式细节可点缀,勿喧宾夺主
|
||||
- **可用性**:高对比可读性、色弱可访问、交互状态清晰(禁用/加载/选中)
|
||||
|
||||
87
frontend/.cursorules/vue.md
Normal file
87
frontend/.cursorules/vue.md
Normal 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
28
frontend/.gitignore
vendored
Normal 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
|
||||
8
frontend/app/web-gold/.editorconfig
Normal file
8
frontend/app/web-gold/.editorconfig
Normal 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
|
||||
6
frontend/app/web-gold/.env.development
Normal file
6
frontend/app/web-gold/.env.development
Normal 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
|
||||
5
frontend/app/web-gold/.env.production
Normal file
5
frontend/app/web-gold/.env.production
Normal 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
1
frontend/app/web-gold/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
30
frontend/app/web-gold/.gitignore
vendored
Normal file
30
frontend/app/web-gold/.gitignore
vendored
Normal 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
|
||||
6
frontend/app/web-gold/.prettierrc.json
Normal file
6
frontend/app/web-gold/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
9
frontend/app/web-gold/.vscode/extensions.json
vendored
Normal file
9
frontend/app/web-gold/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"oxc.oxc-vscode",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
37
frontend/app/web-gold/Dockerfile
Normal file
37
frontend/app/web-gold/Dockerfile
Normal 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;"]
|
||||
|
||||
|
||||
28
frontend/app/web-gold/eslint.config.js
Normal file
28
frontend/app/web-gold/eslint.config.js
Normal 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,
|
||||
])
|
||||
13
frontend/app/web-gold/index.html
Normal file
13
frontend/app/web-gold/index.html
Normal 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>
|
||||
10
frontend/app/web-gold/jsconfig.json
Normal file
10
frontend/app/web-gold/jsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
23
frontend/app/web-gold/nginx.conf
Normal file
23
frontend/app/web-gold/nginx.conf
Normal 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/;
|
||||
# }
|
||||
}
|
||||
|
||||
|
||||
56
frontend/app/web-gold/package.json
Normal file
56
frontend/app/web-gold/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/app/web-gold/postcss.config.js
Normal file
6
frontend/app/web-gold/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
frontend/app/web-gold/public/favicon.ico
Normal file
BIN
frontend/app/web-gold/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
88
frontend/app/web-gold/src/App.vue
Normal file
88
frontend/app/web-gold/src/App.vue
Normal 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>
|
||||
237
frontend/app/web-gold/src/api/auth.js
Normal file
237
frontend/app/web-gold/src/api/auth.js
Normal 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,
|
||||
};
|
||||
74
frontend/app/web-gold/src/api/chat.js
Normal file
74
frontend/app/web-gold/src/api/chat.js
Normal 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
|
||||
})
|
||||
},
|
||||
}
|
||||
67
frontend/app/web-gold/src/api/common.js
Normal file
67
frontend/app/web-gold/src/api/common.js
Normal 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
|
||||
|
||||
|
||||
80
frontend/app/web-gold/src/api/http.js
Normal file
80
frontend/app/web-gold/src/api/http.js
Normal 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
|
||||
|
||||
|
||||
5
frontend/app/web-gold/src/api/tikhub.js
Normal file
5
frontend/app/web-gold/src/api/tikhub.js
Normal 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'
|
||||
|
||||
180
frontend/app/web-gold/src/api/tikhub/README.md
Normal file
180
frontend/app/web-gold/src/api/tikhub/README.md
Normal 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`
|
||||
|
||||
7
frontend/app/web-gold/src/api/tikhub/index.js
Normal file
7
frontend/app/web-gold/src/api/tikhub/index.js
Normal 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'
|
||||
|
||||
55
frontend/app/web-gold/src/api/tikhub/tikhub.js
Normal file
55
frontend/app/web-gold/src/api/tikhub/tikhub.js
Normal 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 }
|
||||
|
||||
87
frontend/app/web-gold/src/api/tikhub/types.js
Normal file
87
frontend/app/web-gold/src/api/tikhub/types.js
Normal 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,
|
||||
}
|
||||
|
||||
1005
frontend/app/web-gold/src/components/LoginModal.vue
Normal file
1005
frontend/app/web-gold/src/components/LoginModal.vue
Normal file
File diff suppressed because it is too large
Load Diff
123
frontend/app/web-gold/src/components/SidebarNav.vue
Normal file
123
frontend/app/web-gold/src/components/SidebarNav.vue
Normal 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>
|
||||
|
||||
|
||||
102
frontend/app/web-gold/src/components/TokenInput.vue
Normal file
102
frontend/app/web-gold/src/components/TokenInput.vue
Normal 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>
|
||||
|
||||
106
frontend/app/web-gold/src/components/TopNav.vue
Normal file
106
frontend/app/web-gold/src/components/TopNav.vue
Normal 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>
|
||||
28
frontend/app/web-gold/src/components/icons/Icon.vue
Normal file
28
frontend/app/web-gold/src/components/icons/Icon.vue
Normal 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>
|
||||
|
||||
|
||||
67
frontend/app/web-gold/src/components/icons/SvgSprite.vue
Normal file
67
frontend/app/web-gold/src/components/icons/SvgSprite.vue
Normal 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
29
frontend/app/web-gold/src/env.d.ts
vendored
Normal 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
|
||||
}
|
||||
|
||||
19
frontend/app/web-gold/src/main.js
Normal file
19
frontend/app/web-gold/src/main.js
Normal 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')
|
||||
51
frontend/app/web-gold/src/router/index.js
Normal file
51
frontend/app/web-gold/src/router/index.js
Normal 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
|
||||
40
frontend/app/web-gold/src/stores/prompt.js
Normal file
40
frontend/app/web-gold/src/stores/prompt.js
Normal 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']
|
||||
}
|
||||
})
|
||||
152
frontend/app/web-gold/src/stores/user.js
Normal file
152
frontend/app/web-gold/src/stores/user.js
Normal 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
|
||||
|
||||
|
||||
62
frontend/app/web-gold/src/stores/voiceCopy.js
Normal file
62
frontend/app/web-gold/src/stores/voiceCopy.js
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
117
frontend/app/web-gold/src/style.css
Normal file
117
frontend/app/web-gold/src/style.css
Normal 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);
|
||||
}
|
||||
17
frontend/app/web-gold/src/types/axios.d.ts
vendored
Normal file
17
frontend/app/web-gold/src/types/axios.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
29
frontend/app/web-gold/src/types/global.d.ts
vendored
Normal file
29
frontend/app/web-gold/src/types/global.d.ts
vendored
Normal 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
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend/app/web-gold/src/types/vue-shim.d.ts
vendored
Normal file
6
frontend/app/web-gold/src/types/vue-shim.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
42
frontend/app/web-gold/src/utils/auth.js
Normal file
42
frontend/app/web-gold/src/utils/auth.js
Normal 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)
|
||||
}
|
||||
|
||||
/** 格式化token(jwt格式) */
|
||||
export const formatToken = (token) => {
|
||||
return 'Bearer ' + token
|
||||
}
|
||||
20
frontend/app/web-gold/src/utils/markdown.js
Normal file
20
frontend/app/web-gold/src/utils/markdown.js
Normal 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 }
|
||||
|
||||
56
frontend/app/web-gold/src/utils/storage.js
Normal file
56
frontend/app/web-gold/src/utils/storage.js
Normal 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,
|
||||
}
|
||||
|
||||
|
||||
100
frontend/app/web-gold/src/utils/token-manager.js
Normal file
100
frontend/app/web-gold/src/utils/token-manager.js
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
55
frontend/app/web-gold/src/utils/url.js
Normal file
55
frontend/app/web-gold/src/utils/url.js
Normal 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 : ''
|
||||
}
|
||||
|
||||
|
||||
15
frontend/app/web-gold/src/views/capcut/CapcutImport.vue
Normal file
15
frontend/app/web-gold/src/views/capcut/CapcutImport.vue
Normal 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>
|
||||
|
||||
|
||||
|
||||
1370
frontend/app/web-gold/src/views/content-style/Benchmark.vue
Normal file
1370
frontend/app/web-gold/src/views/content-style/Benchmark.vue
Normal file
File diff suppressed because it is too large
Load Diff
905
frontend/app/web-gold/src/views/content-style/Copywriting.vue
Normal file
905
frontend/app/web-gold/src/views/content-style/Copywriting.vue
Normal 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>
|
||||
|
||||
|
||||
|
||||
25
frontend/app/web-gold/src/views/dh/Avatar.vue
Normal file
25
frontend/app/web-gold/src/views/dh/Avatar.vue
Normal 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>
|
||||
|
||||
|
||||
|
||||
313
frontend/app/web-gold/src/views/dh/VoiceCopy.vue
Normal file
313
frontend/app/web-gold/src/views/dh/VoiceCopy.vue
Normal 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>
|
||||
|
||||
|
||||
|
||||
146
frontend/app/web-gold/src/views/dh/VoiceGenerate.vue
Normal file
146
frontend/app/web-gold/src/views/dh/VoiceGenerate.vue
Normal 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>
|
||||
|
||||
|
||||
|
||||
781
frontend/app/web-gold/src/views/home/Home.vue
Normal file
781
frontend/app/web-gold/src/views/home/Home.vue
Normal 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;
|
||||
}
|
||||
|
||||
/* 按钮风格(主色 #1890FF,hover #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>
|
||||
15
frontend/app/web-gold/src/views/misc/Download.vue
Normal file
15
frontend/app/web-gold/src/views/misc/Download.vue
Normal 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>
|
||||
|
||||
|
||||
|
||||
15
frontend/app/web-gold/src/views/misc/Help.vue
Normal file
15
frontend/app/web-gold/src/views/misc/Help.vue
Normal 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>
|
||||
|
||||
|
||||
|
||||
15
frontend/app/web-gold/src/views/misc/Theme.vue
Normal file
15
frontend/app/web-gold/src/views/misc/Theme.vue
Normal 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>
|
||||
|
||||
|
||||
|
||||
22
frontend/app/web-gold/src/views/mix/MixEditor.vue
Normal file
22
frontend/app/web-gold/src/views/mix/MixEditor.vue
Normal 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>
|
||||
|
||||
|
||||
|
||||
15
frontend/app/web-gold/src/views/realtime/RealtimeHot.vue
Normal file
15
frontend/app/web-gold/src/views/realtime/RealtimeHot.vue
Normal 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>
|
||||
|
||||
|
||||
|
||||
16
frontend/app/web-gold/src/views/trends/Copywriting.vue
Normal file
16
frontend/app/web-gold/src/views/trends/Copywriting.vue
Normal 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>
|
||||
|
||||
|
||||
|
||||
642
frontend/app/web-gold/src/views/trends/Forecast.vue
Normal file
642
frontend/app/web-gold/src/views/trends/Forecast.vue
Normal 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>
|
||||
217
frontend/app/web-gold/src/views/trends/Heat.vue
Normal file
217
frontend/app/web-gold/src/views/trends/Heat.vue
Normal 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>
|
||||
|
||||
|
||||
|
||||
11
frontend/app/web-gold/tailwind.config.js
Normal file
11
frontend/app/web-gold/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
45
frontend/app/web-gold/tsconfig.json
Normal file
45
frontend/app/web-gold/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
|
||||
11
frontend/app/web-gold/tsconfig.node.json
Normal file
11
frontend/app/web-gold/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.js"]
|
||||
}
|
||||
|
||||
87
frontend/app/web-gold/vite.config.js
Normal file
87
frontend/app/web-gold/vite.config.js
Normal 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)
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
24
frontend/config/axios/config.js
Normal file
24
frontend/config/axios/config.js
Normal 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
146
frontend/doc/readme.md
Normal 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) 子模块:生成数字人
|
||||
- 左侧:配置
|
||||
- 数字人形象选择、背景与模板、脚本来源(文案 + 配音)
|
||||
- 表情与动作控制(关键帧/预设)、唇形同步开关
|
||||
- 分辨率、时长、字幕选项
|
||||
- 生成按钮
|
||||
- 右侧:视频预览与任务队列
|
||||
- 渲染进度、日志、错误重试
|
||||
- 导出(mp4|mov)与推送至“剪映导入”
|
||||
|
||||
#### 模块四:实时热点推送
|
||||
- 榜单看板:平台切换、实时/小时/日视图
|
||||
- 订阅管理:新增/编辑/删除、通知频率、通知方式
|
||||
- 趋势联动:一键进入“热点预测”分析
|
||||
|
||||
#### 模块五:素材混剪
|
||||
- 左侧:文案拆解
|
||||
- 从“文案创作”选择文案,自动分段(镜头/语义)
|
||||
- 段落卡片:时长建议、情绪标签、镜头类型
|
||||
- 右侧:素材匹配与时间线
|
||||
- 素材库搜索(标签/颜色/场景/人物),AI 推荐位
|
||||
- 画布与时间线:轨道(视频/音频/字幕),吸附与对齐
|
||||
- 一键组合:按模版快速生成初版
|
||||
- 导出到“剪映导入”
|
||||
|
||||
#### 模块六:剪映导入
|
||||
- 导入项选择:文案、字幕文件、配音音频、数字人视频、混剪工程
|
||||
- 格式兼容提示:编码、帧率、分辨率、自适配策略
|
||||
- 一键导入:生成剪映工程文件/素材包,打开路径/直接启动剪映(可选)
|
||||
|
||||
### 设计要点(模块化增强)
|
||||
- 色彩:紫色主色、深灰导航、白色内容区;状态色区分任务与错误
|
||||
- 图标:模块与子模块使用语义明确图标,功能与结果分区清晰
|
||||
- 交互:双栏布局优先(左操作右结果/预览),异步任务显著进度提示
|
||||
- 信息层次:概览 > 关键图表/卡片 > 详细表格/日志
|
||||
- 模块联动:子模块间保留“送往/引入”操作(如趋势 → 文案、文案 → 配音/数字人、生成项 → 剪映)
|
||||
- 合规与提示:上传/生成环节均有合规提醒与风险提示,不展示原始受版权保护内容
|
||||
|
||||
### 技术实现提示词
|
||||
- 前端:Vue.js + Vite + Tailwind CSS,组件化(导航、表格、图表、上传、时间线)
|
||||
- 状态管理:用户信息、通知数量、主题、任务队列、订阅配置
|
||||
- 图表:折线/面积/柱状/雷达/词云(ECharts 或 Chart.js)
|
||||
- 文件:音视频上传与转码进度、任务轮询、失败重试
|
||||
- API 接口:模块化设计,参数入参与结构化响应,跨模块数据传递(如选题、风格特征、音视频产物)
|
||||
- 导出:Word/PDF 报告、媒体文件、剪映工程/素材包
|
||||
|
||||
### 工作流快捷入口(横向步骤条)
|
||||
- 对标分析 → 文案创作 → 生成配音/数字人 → 素材混剪 → 剪映导入
|
||||
- 每步显示完成状态与产物摘要,支持回溯与替换
|
||||
187
frontend/doc/金牌内容大师软件需求文档-合规版.md
Normal file
187
frontend/doc/金牌内容大师软件需求文档-合规版.md
Normal 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 风险控制措施
|
||||
- **技术措施**:采用内容特征提取而非内容复制
|
||||
- **法律措施**:完善的用户协议和免责声明
|
||||
- **管理措施**:严格的数据处理和内容生成规范
|
||||
- **监控措施**:实时监控用户行为,防止违规使用
|
||||
39
frontend/hooks/web/useCache.js
Normal file
39
frontend/hooks/web/useCache.js
Normal 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 登录表单
|
||||
}
|
||||
87
frontend/hooks/web/useVoiceText.ts
Normal file
87
frontend/hooks/web/useVoiceText.ts
Normal 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
18
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
frontend/pnpm-workspace.yaml
Normal file
7
frontend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
packages:
|
||||
- build
|
||||
- utils
|
||||
# - packages/*
|
||||
- app/*
|
||||
catalog:
|
||||
|
||||
Reference in New Issue
Block a user