This commit is contained in:
2026-08-05 08:53:43 +08:00
parent e8fa311b59
commit 1166da8115
48 changed files with 8789 additions and 756 deletions
-20
View File
@@ -1,20 +0,0 @@
# video-factory
## Skill routing
When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
Key routing rules:
- Product ideas/brainstorming → invoke /office-hours
- Strategy/scope → invoke /plan-ceo-review
- Architecture → invoke /plan-eng-review
- Design system/plan review → invoke /design-consultation or /plan-design-review
- Full review pipeline → invoke /autoplan
- Bugs/errors → invoke /investigate
- QA/testing site behavior → invoke /qa or /qa-only
- Code review/diff check → invoke /review
- Visual polish → invoke /design-review
- Ship/deploy/PR → invoke /ship or /land-and-deploy
- Save progress → invoke /context-save
- Resume context → invoke /context-restore
- Author a backlog-ready spec/issue → invoke /spec
BIN
View File
Binary file not shown.
-485
View File
@@ -1,485 +0,0 @@
# Video Factory 项目文档
## 概述
Video Factory 是一个 AI 驱动的短视频自动生成平台。用户创建短剧项目,配置演员/场景/道具等素材,通过 AI 生成剧集脚本并调用视频生成 API 自动产出视频。
---
## 技术栈
| 层 | 技术 |
|---|------|
| 语言 | Go 1.22+ |
| Web 框架 | GoFrame v2 (github.com/gogf/gf/v2) |
| 数据库 | SQLite(通过 GoFrame ORM |
| 认证 | JWT (golang-jwt/jwt/v5) |
| 密码 | bcrypt (golang.org/x/crypto/bcrypt) |
| AI 模型 | OpenAI 兼容 API(可对接 DeepSeek/Kimi/Qwen 等) |
| 前端 | Vue 3 + Vitevideo-factory-ui |
---
## 项目结构
```
video-factory/
├── main.go # 入口:路由注册、静态文件、启动轮询
├── common/
│ └── http/
│ └── http.go # RouteRegister — 自动注册路由
├── shortdrama/
│ ├── controller/ # HTTP 控制器层(17 个)
│ │ ├── drama_controller.go # 短剧 CRUD
│ │ ├── scene_controller.go # 场景 CRUD
│ │ ├── character_controller.go # 演员 CRUD
│ │ ├── prop_controller.go # 道具 CRUD
│ │ ├── bgm_controller.go # 背景音 CRUD
│ │ ├── episode_controller.go # 剧集 CRUD + 脚本生成
│ │ ├── generation_controller.go # 视频生成/轮询/分段
│ │ ├── user_controller.go # 用户登录/改密
│ │ ├── agent_controller.go # 代理商管理
│ │ ├── customer_controller.go # 客户管理
│ │ ├── transaction_controller.go # 交易记录
│ │ ├── payment_order_controller.go # 支付订单
│ │ ├── payment_channel_trade_controller.go # 支付渠道流水
│ │ ├── payment_config_controller.go # 支付配置
│ │ ├── model_config_controller.go # 模型配置
│ │ ├── user_model_config_controller.go # 用户模型配置
│ │ └── region_pricing_controller.go # 区域定价
│ ├── service/ # 业务逻辑层(17 个)
│ │ ├── drama_service.go # 短剧 CRUD + 工作空间管理
│ │ ├── scene_service.go # 场景
│ │ ├── character_service.go # 演员(含文件保存)
│ │ ├── prop_service.go # 道具
│ │ ├── background_music_service.go # 背景音
│ │ ├── episode_service.go # 剧集 + AI 脚本生成
│ │ ├── generation_service.go # 视频生成核心流水线
│ │ ├── user_service.go # 登录/JWT
│ │ ├── agent_service.go # 代理商
│ │ ├── customer_service.go # 客户
│ │ ├── transaction_service.go # 交易
│ │ ├── payment_order_service.go # 支付(微信/支付宝/线下)
│ │ ├── payment_channel_trade_service.go # 渠道流水
│ │ ├── payment_config_service.go # 支付配置
│ │ ├── model_config_service.go # 模型配置(缓存)
│ │ ├── user_model_config_service.go # 用户模型配置(合并系统+用户)
│ │ └── region_pricing_service.go # 区域定价(缓存)
│ ├── dao/ # 数据访问层(17 个,每表一个)
│ │ ├── drama_dao.go
│ │ ├── scene_dao.go
│ │ ├── character_dao.go
│ │ ├── episode_dao.go
│ │ ├── prop_dao.go
│ │ ├── background_music_dao.go
│ │ ├── generation_task_dao.go
│ │ ├── user_dao.go
│ │ ├── agent_profile_dao.go
│ │ ├── customer_profile_dao.go
│ │ ├── account_transaction_dao.go
│ │ ├── payment_order_dao.go
│ │ ├── payment_channel_trade_dao.go
│ │ ├── payment_config_dao.go
│ │ ├── model_config_dao.go
│ │ ├── user_model_config_dao.go
│ │ └── region_pricing_dao.go
│ ├── model/
│ │ ├── entity/ # 数据库实体(每表一个)
│ │ ├── dto/ # 请求/响应结构体(含 g.Meta 路由信息)
│ │ ├── domain/ # 领域模型
│ │ │ └── shot.go # 镜头模型 + 镜头转文本方法
│ │ ├── agent_output.go # AI Agent 输出解析
│ │ └── segment_output.go # 分段生成输出类型
│ ├── agent/ # AI Agent 模块
│ │ ├── types.go # 类型定义
│ │ ├── chat_model.go # LLM API 调用(OpenAI 兼容)
│ │ ├── react_agent.go # ReAct 智能体引擎
│ │ ├── tools.go # Agent 工具集
│ │ └── context.go # Agent 上下文
│ ├── middleware/
│ │ └── auth_middleware.go # JWT 鉴权中间件
│ ├── config/
│ │ └── shot_duration.go # 镜头时长提示词
│ └── consts/
│ ├── public/
│ │ ├── table_name.go # 数据库表名常量
│ │ └── content_type.go # 内容类型/字段定义
│ └── status.go # 剧集/任务状态常量
└── workspace/ # 上传文件存储目录
```
---
## 架构模式
分层结构:**Controller → Service → DAO → SQLite**
```
HTTP 请求
Controller(接收请求、参数校验、返回响应)
Service(业务逻辑、事务管理、AI 调用)
DAO(数据访问、ORM 操作)
SQLite
```
- 每一层都是独立的包,通过包级变量暴露单例(如 `var DramaService = new(dramaService)`
- 每张数据库表对应一个 DAO、一个 Service、一个 Controller17×17 一对一映射)
### 路由注册机制
`RouteRegister``common/http/http.go`)通过反射获取结构体名,转为 kebab-case 作为 URL 前缀:
```go
type scene struct{} // → 前缀 /scene
type modelConfig struct{} // → 前缀 /model-config
```
GoFrame v2 将 Controller 方法名转为 kebab-case 拼接到前缀后:
```go
// scene_controller.go
func (c *scene) List(ctx, req) GET /scene/list
func (c *scene) Add(ctx, req) POST /scene/add
```
完整路由表(详见下方 API 章节)。
---
## 数据库
使用 SQLite`init()` 函数自动建表,支持旧表迁移(ALTER TABLE ADD COLUMN 兼容)。
### 短剧相关表(6 张)
| 表名 | 实体 | 说明 |
|------|------|------|
| `short_drama` | Drama | 短剧项目(标题/类型/时长/分辨率/配置) |
| `short_drama_episode` | Episode | 剧集(序号/标题/脚本/状态/视频URL) |
| `short_drama_character` | Character | 演员(名称/描述/声音文件/形象文件) |
| `short_drama_scene` | Scene | 场景(名称/描述/图片) |
| `short_drama_prop` | Prop | 道具(名称/描述/图片) |
| `short_drama_background_music` | BackgroundMusic | 背景音乐(名称/文件) |
### 生成任务表(1 张)
| 表名 | 说明 |
|------|------|
| `short_drama_generation_task` | 视频生成任务(剧集/分段/状态/任务ID/脚本/模型名) |
### 用户/代理商/客户表(3 张)
| 表名 | 说明 |
|------|------|
| `user` | 用户(角色:admin/agent/customer |
| `agent_profile` | 代理商资料(最大客户数/到期时间/区域保护) |
| `customer_profile` | 客户资料(关联代理商/余额) |
### 支付相关表(3 张)
| 表名 | 说明 |
|------|------|
| `payment_order` | 支付订单(用户/金额/渠道/状态/类型) |
| `payment_channel_trade` | 渠道流水(支付单号/渠道单号/状态) |
| `payment_config` | 支付配置(渠道/商户ID/密钥) |
### 其他表(4 张)
| 表名 | 说明 |
|------|------|
| `short_drama_model_config` | 模型配置(系统级,模型列表/参数) |
| `user_model_config` | 用户模型配置(用户自定义 API Key/模型选择) |
| `account_transaction` | 账户交易流水(充值/扣费) |
| `region_pricing` | 区域定价(省份/地区/套餐/价格) |
---
## API 路由表
所有请求均需 JWT 鉴权(除 `/user/login`),统一 JSON 响应格式 `{"code":0,"message":"OK","data":...}`
### 短剧管理
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/drama/list` | 短剧列表(分页) |
| POST | `/drama/create` | 创建短剧 |
| GET | `/drama/get` | 短剧详情(含关联演员/场景/道具/背景音) |
| POST | `/drama/update` | 更新短剧 |
| POST | `/drama/delete` | 删除短剧(级联删除关联数据) |
| GET | `/drama/field-definitions` | 获取内容类型字段定义 |
### 场景
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/scene/list` | 场景列表 |
| POST | `/scene/add` | 添加场景(支持上传图片) |
| POST | `/scene/update` | 更新场景 |
| POST | `/scene/delete` | 删除场景 |
### 演员
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/character/list` | 演员列表 |
| POST | `/character/add` | 添加演员(支持上传声音/形象) |
| POST | `/character/update` | 更新演员 |
| POST | `/character/delete` | 删除演员 |
### 道具
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/prop/list` | 道具列表 |
| POST | `/prop/add` | 添加道具 |
| POST | `/prop/update` | 更新道具 |
| POST | `/prop/delete` | 删除道具 |
### 背景音乐
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/bgm/list` | 背景音乐列表 |
| POST | `/bgm/add` | 添加背景音乐 |
| POST | `/bgm/update` | 更新背景音乐 |
| POST | `/bgm/delete` | 删除背景音乐 |
### 剧集
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/episode/list` | 剧集列表(分页) |
| POST | `/episode/add` | 添加剧集 |
| POST | `/episode/update` | 更新剧集 |
| POST | `/episode/delete` | 删除剧集 |
| POST | `/episode/generate-script` | AI 生成脚本 |
### 视频生成
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/generation/generate` | 提交一集视频生成任务 |
| GET | `/generation/poll` | 轮询生成状态 |
| GET | `/generation/episode/task` | 获取剧集所有生成任务 |
| POST | `/generation/segment/continue` | 继续分段生成(含反馈) |
| POST | `/generation/segment/feedback` | 分段反馈 |
### 用户
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/user/login` | 登录(公开路径) |
| POST | `/user/change-password` | 修改密码 |
### 模型配置
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/model-config/get-model-list` | 模型列表 |
| POST | `/model-config/save-model-config` | 保存模型配置 |
| GET | `/user-model-config/get-user-config` | 获取用户模型配置 |
| POST | `/user-model-config/save-user-config` | 保存用户模型配置 |
| GET | `/user-model-config/get-user-model-list` | 用户可用模型列表 |
### 支付
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/payment-order/prepay` | 创建支付订单 |
| GET | `/payment-order/status` | 查询支付状态 |
| POST | `/payment-order/confirm-offline` | 线下确认 |
| ALL | `/payment-order/notify-wechat` | 微信回调 |
| ALL | `/payment-order/notify-alipay` | 支付宝回调 |
| GET | `/payment-channel-trade/list-by-order` | 订单渠道流水 |
| GET | `/payment-config/get` | 支付配置 |
| POST | `/payment-config/save` | 保存支付配置 |
### 代理商/客户/交易
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/agent/list` | 代理商列表 |
| POST | `/agent/create` | 创建代理商 |
| POST | `/agent/update` | 更新代理商 |
| ALL | `/agent/get` | 代理商详情 |
| POST | `/agent/create-renew-order` | 创建续费订单 |
| GET | `/agent/list-renewals` | 续费记录 |
| GET | `/customer/list` | 客户列表 |
| POST | `/customer/create` | 创建客户 |
| POST | `/customer/update` | 更新客户 |
| ALL | `/customer/detail` | 客户详情 |
| GET | `/transaction/list` | 交易流水 |
| POST | `/transaction/recharge` | 充值 |
### 区域定价
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/region-pricing/list` | 定价列表 |
| POST | `/region-pricing/save` | 保存定价 |
| ALL | `/region-pricing/delete` | 删除定价 |
| ALL | `/region-pricing/regions` | 区域列表 |
| ALL | `/region-pricing/cascades` | 级联数据 |
---
## 核心业务流程
### 1. 创建短剧
```
用户创建短剧 → 填写标题/类型/时长 → 添加演员/场景/道具/背景音 → 添加剧集
```
- 短剧内容类型:短剧、漫剧、广告视频
- 创建时自动创建 workspace 目录结构
### 2. 生成脚本
```
用户选择剧集 → 点击"生成脚本"
→ 系统加载短剧上下文(演员/场景/道具列表)
→ 调用 LLM chat completion(简单对话,非 ReAct
→ 模型返回 JSON 格式的镜头数组(Shot[]
→ 解析验证,失败则重试修正
```
每个镜头包含:时间起止、画面描述、台词、旁白、运镜、景别、出演人物、场景、道具。
### 3. 生成视频(核心流程)
```
用户选择剧集 → 点击"生成视频"
┌─ 1. 余额检查(客户角色)并预扣费
├─ 2. 按视频模型约束将剧集时长拆分为多段(15-60s/段)
├─ 3. 并行或串行生成每段:
│ ├─ a. 如果脚本是 JSON 镜头格式 → 直接按时间提取段内容
│ ├─ b. 否则 → ReAct Agent 循环(最多 15 步):
│ │ 思考 → 工具调用 → 观察结果 → 最终输出
│ │ 工具:parse_script / analyze_script_for_episode / generate_scene_image
│ ├─ c. 解析 Agent 输出为 SegmentOutput
│ ├─ d. 提取角色/场景参考图片
│ ├─ e. 构建视频 API 请求体并提交
│ └─ f. 保存 video_task_id
├─ 4. 串行模式下等待前段完成(用于首帧参考)
└─ 5. 后台轮询(每 15s)检查视频生成状态
```
#### 分段策略(`calcSegDurs`
根据视频模型的最大时长限制,将整集拆分为若干段:
- 短剧(默认):每段 15-60 秒
- 漫剧:每段 15-30 秒
- 广告视频:整段 15-60 秒
#### ReAct Agent 工具
| 工具 | 功能 |
|------|------|
| `parse_script` | 将原始剧本按 `---` 拆分为多集,提取标题和内容 |
| `analyze_script_for_episode` | 分析单集剧本,按空行拆分为场景,识别出场演员 |
| `generate_scene_image` | 从场景库中匹配图片返回 base64 |
### 4. 视频模型对接
项目通过 OpenAI 兼容 API 对接视频生成模型。流程:
1. 构建请求体(含角色参考图URL、场景图片、脚本描述)
2. POST 提交视频生成任务
3. 轮询任务状态(pending → generating → completed/failed
4. 完成后拼接各段视频为最终输出
---
## 权限与角色
| 角色 | 说明 |
|------|------|
| `admin` | 管理员,查看所有数据 |
| `agent` | 代理商,管理名下客户,查看区域客户数据 |
| `customer` | 客户,仅查看自己的数据,生成视频扣费 |
登录默认账号:
- admin / Tongli686^*^(管理员)
- test1 / Tongli686^*^(代理商)
---
## AI Agent 模块
### 架构
```
agent/
├── types.go # ChatMessage/ToolInfo/ToolCall 类型定义
├── chat_model.go # OpenAI 兼容 API 调用(含重试/限流处理)
├── react_agent.go # ReAct 循环引擎(思考→行动→观察→重复)
├── tools.go # 工具注册与实现
└── context.go # 上下文(DramaID 注入)
```
### 调用模式
**简单 Chat Completion**(脚本生成用):
```
System Prompt + User Input → LLM → JSON 输出
```
**ReAct Agent**(视频生成用):
```
System Prompt + User Input
→ LLM 思考 → 工具调用(parse_script → 观察结果
→ LLM 思考 → 工具调用(analyze_script_for_episode → 观察结果
→ LLM 思考 → 工具调用(generate_scene_image → 观察结果
→ LLM 最终回答 → 输出 JSON
```
最大 15 步循环,支持指数退避重试。
---
## JWT 认证
- Token 在 `/user/login` 获取
- 所有请求(除 `/user/login`)需在 Header 携带 `Authorization: Bearer <token>`
- 中间件从 token 解析 `userId``role``agentId` 注入请求上下文
- 过期时间默认 7 天
---
## 文件存储
上传文件统一存储在 `workspace/{短剧标题}/` 目录下:
```
workspace/{短剧标题}/
├── 产出视频/
├── 演员形象/
├── 演员声音/
├── 场景/
├── 道具/
└── 背景音乐/
```
---
## 配置
- `config.yml`:镜头时长规则(按内容类型)、画风约束
- `.env`:开发环境代理目标(前端项目)
- 用户模型配置存数据库 `short_drama_model_config``user_model_config`
### 模型配置继承
```
系统模型配置(short_drama_model_config
└── 用户模型配置(user_model_config,可选覆盖 API Key/模型选择)
└── GetMergedConfig() 合并两者,用户配置优先
```
系统配置 + 用户配置 → `MergedModelConfig`(运行时最终配置)
-251
View File
@@ -1,251 +0,0 @@
# Video Factory 后端项目缺陷与优化说明
> 基于当前代码实际扫描(2026-07-24 最新)。已在此会话中修复的项不重复列出。
---
## 一、安全缺陷
### 1.1 JWT 密钥硬编码
**文件**: `shortdrama/service/user_service.go:15`
```go
const jwtSecret = "video-factory-jwt-secret-2024"
```
密钥硬编码源码中,所有部署共用同一密钥。泄露后可用任意用户 Token 伪造身份。
**修复**: 从环境变量 `JWT_SECRET` 读取。
**严重程度**: 🔴 严重
---
### 1.2 支付签名使用 MD5
**文件**: `shortdrama/service/payment_order_service.go:362,447`
- 微信支付使用 MD5 签名(微信推荐 HMAC-SHA256
- 支付宝使用 `md5.Sum([]byte(raw + privateKey))` 而非标准 RSA2
**严重程度**: 🔴 严重
---
### 1.3 支付通知 URL 为空
**文件**: `shortdrama/service/payment_order_service.go:309,384`
```go
"notify_url": "", // 需要实际外网可访问地址
```
微信和支付宝通知地址均为空,支付渠道无法回调通知订单状态变更。
**严重程度**: 🔴 严重
---
### 1.4 支付回调错误被静默忽略
**文件**: `shortdrama/controller/payment_order_controller.go:67,76`
```go
_ = service.PaymentService.HandleNotify(ctx, "wechat", body)
```
`HandleNotify` 返回值被忽略,即使处理失败也返回 `SUCCESS`,支付渠道不会重试,可能导致资金损失。
**严重程度**: 🔴 严重
---
### 1.5 微信支付 IP 写死
**文件**: `shortdrama/service/payment_order_service.go:308`
```go
"spbill_create_ip": "127.0.0.1",
```
全部走 localhost 可能触发微信风控。
**严重程度**: 🟠 重要
---
### 1.6 API Key 明文写入日志
**文件**: `shortdrama/service/episode_service.go:216`
```go
g.Log().Warningf(ctx, "... ApiKey=%q ...", modelCfg.ApiKey)
```
模型 API Key 在日志中明文输出。
**严重程度**: 🟠 重要
---
### 1.7 订单号随机性弱
**文件**: `shortdrama/service/payment_order_service.go:290`
```go
r := rand.Intn(10000) // 仅 10000 种
return fmt.Sprintf("PAY%s%04d", now.Format("20060102150405"), r)
```
使用 `math/rand`(非加密安全),同一秒内仅 10000 种订单号,高并发可重复。
**严重程度**: 🟠 重要
---
### 1.8 支付 nonce 使用 math/rand
**文件**: `shortdrama/service/payment_order_service.go:457`
```go
b[i] = letters[rand.Intn(len(letters))]
```
nonce 可被预测。
**严重程度**: 🟠 重要
---
### 1.9 CORS 未限制来源
**文件**: `common/http/http.go:28`
```go
r.Response.CORS(r.Response.DefaultCORSOptions())
```
允许所有来源跨域访问。
**严重程度**: 🟠 重要
---
### 1.10 客户默认密码为手机号后 6 位
**文件**: `shortdrama/service/customer_service.go:57-61`
默认密码 = 手机号后 6 位,且 `bcryptGenerate` 错误被忽略(失败时密码为空)。
**严重程度**: 🟠 重要
---
### 1.11 API Key 明文存储
**文件**: `shortdrama/dao/payment_config_dao.go:25`, `dao/user_model_config_dao.go:34,71`
支付 API Key、AppSecret、PrivateKey 及用户模型 API Key 在 SQLite 中明文存储。
**严重程度**: 🟠 重要
---
## 二、严重逻辑缺陷
### 2.1 并行模式永久死代码
**文件**: `shortdrama/service/generation_service.go:95`
```go
// 注释说"默认使用并行生成模式"
mode = "serial"
```
`mode` 参数被无条件覆盖为 `"serial"`,下方完整的并行 goroutine 代码行(约 30 行)完全不可达。注释与行为矛盾。
**严重程度**: 🔴 严重
---
## 三、代码质量问题
### 3.1 generation_service.go 文件过大
**文件**: `shortdrama/service/generation_service.go`2688 行,60+ 个函数)
职责涵盖:Agent 调用、视频提交/合并、ffmpeg 操作、轮询引擎、缓存、prompt 构建、上下文构建、文件操作等。
**建议**: 拆分为 `generation.go``poller.go``prompt.go``fileops.go``ffmpeg.go`
**严重程度**: 🟡 中等
---
## 四、架构与设计问题
### 4.1 DAO 用 init() 执行 DDL 迁移
**文件**: `shortdrama/dao/*dao.go`17 个文件)
DDL 散落在 `init()` 中,执行顺序依赖包导入顺序,SQLite `DROP COLUMN` 被静默忽略,无回滚,无超时,不可测试。
**建议**: 使用 migrate/sqlite 等迁移工具统一管理。
**严重程度**: 🟠 重要
---
### 4.2 缺少审计日志
所有 CRUD 操作(短剧、演员、场景、代理商、充值等)均无审计记录。充值无操作人身份记录。
**建议**: 关键操作记录 `(actor_id, action, target_type, target_id, detail)`
**严重程度**: 🟡 中等
---
### 4.3 视频轮询器无指数退避
**文件**: `shortdrama/service/generation_service.go:1630`
```go
ticker := time.NewTicker(15 * time.Second)
```
恒定 15s 轮询,任务长时间 RUNNING 时无谓消耗 DB。
**建议**: 实现指数退避(15s → 30s → 60s → 120s)。
**严重程度**: 🟡 中等
---
## 五、测试覆盖
### 5.1 无单元测试
约 100+ `.go` 文件,`_test.go` 数量为 **0**。Agent 调用、分段算法、支付签名、权限校验、DAO 操作等核心逻辑均无覆盖。
**严重程度**: 🟠 重要
---
### 5.2 无集成测试
API 端点无集成测试,无法验证路由、参数解析、响应格式。DDL 迁移完全不可测试。
**严重程度**: 🟡 中等
---
## 严重程度汇总
| 级别 | 含义 | 数量 |
|------|------|------|
| 🔴 严重 | 安全性/功能性问题 | 5 |
| 🟠 重要 | 有实际风险 | 12 |
| 🟡 中等 | 代码质量/架构 | 4 |
| 🟢 轻微 | 代码整洁性 | 0 |
+6
View File
@@ -0,0 +1,6 @@
# 默认登录账号密码(开发/生产通用)
#VITE_DEFAULT_USERNAME=customer1
#VITE_DEFAULT_PASSWORD=Tongli686^*^
# Vite 开发服务器代理目标(仅本地 dev 模式使用,生产由 nginx 代理)
VITE_API_PROXY_TARGET=http://localhost:3006
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.cache
# 本地环境变量覆盖(每个开发者可自定义)
.env.local
.env.*.local
.gstack/
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video Factory</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+2986
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "video-factory-ui",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"axios": "^1.6.0",
"element-plus": "^2.5.0",
"json-schema-editor": "github:wusij/json-schema",
"pinia": "^2.1.0",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"sass-embedded": "^1.100.0",
"vite": "^5.0.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+25
View File
@@ -0,0 +1,25 @@
import request from './request.js'
export function listAgents(params) {
return request.get('/agent/list', { params })
}
export function createAgent(data) {
return request.post('/agent/create', data)
}
export function updateAgent(data) {
return request.post('/agent/update', data)
}
export function createRenewOrder(agentId, duration, pricingId) {
return request.post('/agent/create-renew-order', { agent_id: agentId, duration, pricing_id: pricingId })
}
export function getAgentDetail(id) {
return request.post('/agent/get', { id })
}
export function listAgentRenewals(agentId) {
return request.get('/agent/list-renewals', { params: { agent_id: agentId } })
}
+5
View File
@@ -0,0 +1,5 @@
import request from './request.js'
export function login(data) {
return request.post('/user/login', data)
}
+21
View File
@@ -0,0 +1,21 @@
import request from './request'
export function listBackgroundMusic(dramaId) {
return request.get('/bgm/list', { params: { dramaId } })
}
export function addBackgroundMusic(formData) {
return request.post('/bgm/add', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export function updateBackgroundMusic(formData) {
return request.post('/bgm/update', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export function deleteBackgroundMusic(data) {
return request.post('/bgm/delete', data)
}
+21
View File
@@ -0,0 +1,21 @@
import request from './request'
export function addCharacter(formData) {
return request.post('/character/add', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export function updateCharacter(formData) {
return request.post('/character/update', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export function deleteCharacter(data) {
return request.post('/character/delete', data)
}
export function listCharacters(dramaId) {
return request.get('/character/list', { params: { dramaId } })
}
+17
View File
@@ -0,0 +1,17 @@
import request from './request'
export function getModelConfig(params) {
return request.get('/model-config/get-model-list', { params })
}
export function saveModelConfig(data) {
return request.post('/model-config/save-model-config', data)
}
export function getPaymentConfig() {
return request.get('/payment-config/get')
}
export function savePaymentConfig(data) {
return request.post('/payment-config/save', data)
}
+17
View File
@@ -0,0 +1,17 @@
import request from './request.js'
export function listCustomers(params) {
return request.get('/customer/list', { params })
}
export function createCustomer(data) {
return request.post('/customer/create', data)
}
export function customerDetail(id) {
return request.post('/customer/detail', { id })
}
export function updateCustomer(data) {
return request.post('/customer/update', data)
}
+25
View File
@@ -0,0 +1,25 @@
import request from './request'
export function listDramas(page = 1, pageSize = 20, keyword = '') {
return request.get('/drama/list', { params: { page, pageSize, keyword } })
}
export function createDrama(data) {
return request.post('/drama/create', data)
}
export function getDrama(id) {
return request.get('/drama/get', { params: { id } })
}
export function updateDrama(data) {
return request.post('/drama/update', data)
}
export function deleteDrama(id) {
return request.post('/drama/delete', { id })
}
export function getFieldDefinitions() {
return request.get('/drama/field-definitions')
}
+33
View File
@@ -0,0 +1,33 @@
import request from './request'
export function listEpisodes(dramaId, page = 1, pageSize = 100, keyword = '') {
return request.get('/episode/list', { params: { dramaId, page, pageSize, keyword } })
}
export function addEpisode(data) {
return request.post('/episode/add', data, { timeout: 0 })
}
export function updateEpisode(data) {
return request.post('/episode/update', data, { timeout: 0 })
}
export function deleteEpisode(data) {
return request.post('/episode/delete', data)
}
export function generateEpisode(data) {
return request.post('/generation/generate', data)
}
export function pollEpisode(epId) {
return request.get('/generation/poll', { params: { epId } })
}
export function getEpisodeTasks(epId) {
return request.get('/generation/episode/task', { params: { epId } })
}
export function generateEpisodeScript(data) {
return request.post('/episode/generate-script', data, { timeout: 0 })
}
+13
View File
@@ -0,0 +1,13 @@
import request from './request.js'
export function prepay(data) {
return request.post('/payment-order/prepay', data)
}
export function queryPaymentStatus(orderNo) {
return request.get('/payment-order/status', { params: { order_no: orderNo } })
}
export function confirmOffline(orderNo) {
return request.post('/payment-order/confirm-offline', { order_no: orderNo })
}
+21
View File
@@ -0,0 +1,21 @@
import request from './request'
export function listProps(dramaId) {
return request.get('/prop/list', { params: { dramaId } })
}
export function addProp(formData) {
return request.post('/prop/add', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export function updateProp(formData) {
return request.post('/prop/update', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export function deleteProp(data) {
return request.post('/prop/delete', data)
}
+21
View File
@@ -0,0 +1,21 @@
import request from './request.js'
export function listRegionPricing(data) {
return request.get('/region-pricing/list', { params: data })
}
export function saveRegionPricing(data) {
return request.post('/region-pricing/save', data)
}
export function deleteRegionPricing(id) {
return request.post('/region-pricing/delete', { id })
}
export function listRegions() {
return request.post('/region-pricing/regions')
}
export function listRegionCascades() {
return request.post('/region-pricing/cascades')
}
+44
View File
@@ -0,0 +1,44 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
const request = axios.create({
timeout: 180000
})
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = 'Bearer ' + token
}
return config
})
function redirectLogin() {
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/#/login'
}
request.interceptors.response.use(
response => {
const data = response.data
if (data.code !== 0) {
if (data.code === 401) {
redirectLogin()
return Promise.reject(new Error(data.message))
}
ElMessage.error(data.message || '请求失败')
return Promise.reject(new Error(data.message))
}
return data.data
},
error => {
if (error.response?.status === 401) {
redirectLogin()
}
ElMessage.error(error.message || '网络错误')
return Promise.reject(error)
}
)
export default request
+21
View File
@@ -0,0 +1,21 @@
import request from './request'
export function listScenes(dramaId) {
return request.get('/scene/list', { params: { dramaId } })
}
export function addScene(formData) {
return request.post('/scene/add', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export function updateScene(formData) {
return request.post('/scene/update', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export function deleteScene(data) {
return request.post('/scene/delete', data)
}
+9
View File
@@ -0,0 +1,9 @@
import request from './request'
export function continueSegment(taskId) {
return request.post('/generation/segment/continue', { taskId })
}
export function feedbackSegment(data) {
return request.post('/generation/segment/feedback', data)
}
+9
View File
@@ -0,0 +1,9 @@
import request from './request.js'
export function listTransactions(userId = 0, page = 1, pageSize = 20, typeFilter = '') {
return request.get('/transaction/list', { params: { user_id: userId, type: typeFilter, page, page_size: pageSize } })
}
export function recharge(data) {
return request.post('/transaction/recharge', data)
}
+14
View File
@@ -0,0 +1,14 @@
import request from './request'
export function getUserConfig() {
return request.get('/user-model-config/get-user-config')
}
export function saveUserConfig(data) {
return request.post('/user-model-config/save-user-config', data)
}
export function getUserModelList(params) {
return request.get('/user-model-config/get-user-model-list', { params })
}
+589
View File
@@ -0,0 +1,589 @@
[
{
"label": "北京市",
"value": "北京市",
"children": [
{ "label": "北京市", "value": "北京市" }
]
},
{
"label": "天津市",
"value": "天津市",
"children": [
{ "label": "天津市", "value": "天津市" }
]
},
{
"label": "河北省",
"value": "河北省",
"children": [
{ "label": "石家庄市", "value": "石家庄市" },
{ "label": "唐山市", "value": "唐山市" },
{ "label": "秦皇岛市", "value": "秦皇岛市" },
{ "label": "邯郸市", "value": "邯郸市" },
{ "label": "邢台市", "value": "邢台市" },
{ "label": "保定市", "value": "保定市" },
{ "label": "张家口市", "value": "张家口市" },
{ "label": "承德市", "value": "承德市" },
{ "label": "沧州市", "value": "沧州市" },
{ "label": "廊坊市", "value": "廊坊市" },
{ "label": "衡水市", "value": "衡水市" }
]
},
{
"label": "山西省",
"value": "山西省",
"children": [
{ "label": "太原市", "value": "太原市" },
{ "label": "大同市", "value": "大同市" },
{ "label": "阳泉市", "value": "阳泉市" },
{ "label": "长治市", "value": "长治市" },
{ "label": "晋城市", "value": "晋城市" },
{ "label": "朔州市", "value": "朔州市" },
{ "label": "晋中市", "value": "晋中市" },
{ "label": "运城市", "value": "运城市" },
{ "label": "忻州市", "value": "忻州市" },
{ "label": "临汾市", "value": "临汾市" },
{ "label": "吕梁市", "value": "吕梁市" }
]
},
{
"label": "内蒙古自治区",
"value": "内蒙古自治区",
"children": [
{ "label": "呼和浩特市", "value": "呼和浩特市" },
{ "label": "包头市", "value": "包头市" },
{ "label": "乌海市", "value": "乌海市" },
{ "label": "赤峰市", "value": "赤峰市" },
{ "label": "通辽市", "value": "通辽市" },
{ "label": "鄂尔多斯市", "value": "鄂尔多斯市" },
{ "label": "呼伦贝尔市", "value": "呼伦贝尔市" },
{ "label": "巴彦淖尔市", "value": "巴彦淖尔市" },
{ "label": "乌兰察布市", "value": "乌兰察布市" },
{ "label": "兴安盟", "value": "兴安盟" },
{ "label": "锡林郭勒盟", "value": "锡林郭勒盟" },
{ "label": "阿拉善盟", "value": "阿拉善盟" }
]
},
{
"label": "辽宁省",
"value": "辽宁省",
"children": [
{ "label": "沈阳市", "value": "沈阳市" },
{ "label": "大连市", "value": "大连市" },
{ "label": "鞍山市", "value": "鞍山市" },
{ "label": "抚顺市", "value": "抚顺市" },
{ "label": "本溪市", "value": "本溪市" },
{ "label": "丹东市", "value": "丹东市" },
{ "label": "锦州市", "value": "锦州市" },
{ "label": "营口市", "value": "营口市" },
{ "label": "阜新市", "value": "阜新市" },
{ "label": "辽阳市", "value": "辽阳市" },
{ "label": "盘锦市", "value": "盘锦市" },
{ "label": "铁岭市", "value": "铁岭市" },
{ "label": "朝阳市", "value": "朝阳市" },
{ "label": "葫芦岛市", "value": "葫芦岛市" }
]
},
{
"label": "吉林省",
"value": "吉林省",
"children": [
{ "label": "长春市", "value": "长春市" },
{ "label": "吉林市", "value": "吉林市" },
{ "label": "四平市", "value": "四平市" },
{ "label": "辽源市", "value": "辽源市" },
{ "label": "通化市", "value": "通化市" },
{ "label": "白山市", "value": "白山市" },
{ "label": "松原市", "value": "松原市" },
{ "label": "白城市", "value": "白城市" },
{ "label": "延边朝鲜族自治州", "value": "延边朝鲜族自治州" }
]
},
{
"label": "黑龙江省",
"value": "黑龙江省",
"children": [
{ "label": "哈尔滨市", "value": "哈尔滨市" },
{ "label": "齐齐哈尔市", "value": "齐齐哈尔市" },
{ "label": "鸡西市", "value": "鸡西市" },
{ "label": "鹤岗市", "value": "鹤岗市" },
{ "label": "双鸭山市", "value": "双鸭山市" },
{ "label": "大庆市", "value": "大庆市" },
{ "label": "伊春市", "value": "伊春市" },
{ "label": "佳木斯市", "value": "佳木斯市" },
{ "label": "七台河市", "value": "七台河市" },
{ "label": "牡丹江市", "value": "牡丹江市" },
{ "label": "黑河市", "value": "黑河市" },
{ "label": "绥化市", "value": "绥化市" },
{ "label": "大兴安岭地区", "value": "大兴安岭地区" }
]
},
{
"label": "上海市",
"value": "上海市",
"children": [
{ "label": "上海市", "value": "上海市" }
]
},
{
"label": "江苏省",
"value": "江苏省",
"children": [
{ "label": "南京市", "value": "南京市" },
{ "label": "无锡市", "value": "无锡市" },
{ "label": "徐州市", "value": "徐州市" },
{ "label": "常州市", "value": "常州市" },
{ "label": "苏州市", "value": "苏州市" },
{ "label": "南通市", "value": "南通市" },
{ "label": "连云港市", "value": "连云港市" },
{ "label": "淮安市", "value": "淮安市" },
{ "label": "盐城市", "value": "盐城市" },
{ "label": "扬州市", "value": "扬州市" },
{ "label": "镇江市", "value": "镇江市" },
{ "label": "泰州市", "value": "泰州市" },
{ "label": "宿迁市", "value": "宿迁市" }
]
},
{
"label": "浙江省",
"value": "浙江省",
"children": [
{ "label": "杭州市", "value": "杭州市" },
{ "label": "宁波市", "value": "宁波市" },
{ "label": "温州市", "value": "温州市" },
{ "label": "嘉兴市", "value": "嘉兴市" },
{ "label": "湖州市", "value": "湖州市" },
{ "label": "绍兴市", "value": "绍兴市" },
{ "label": "金华市", "value": "金华市" },
{ "label": "衢州市", "value": "衢州市" },
{ "label": "舟山市", "value": "舟山市" },
{ "label": "台州市", "value": "台州市" },
{ "label": "丽水市", "value": "丽水市" }
]
},
{
"label": "安徽省",
"value": "安徽省",
"children": [
{ "label": "合肥市", "value": "合肥市" },
{ "label": "芜湖市", "value": "芜湖市" },
{ "label": "蚌埠市", "value": "蚌埠市" },
{ "label": "淮南市", "value": "淮南市" },
{ "label": "马鞍山市", "value": "马鞍山市" },
{ "label": "淮北市", "value": "淮北市" },
{ "label": "铜陵市", "value": "铜陵市" },
{ "label": "安庆市", "value": "安庆市" },
{ "label": "黄山市", "value": "黄山市" },
{ "label": "滁州市", "value": "滁州市" },
{ "label": "阜阳市", "value": "阜阳市" },
{ "label": "宿州市", "value": "宿州市" },
{ "label": "六安市", "value": "六安市" },
{ "label": "亳州市", "value": "亳州市" },
{ "label": "池州市", "value": "池州市" },
{ "label": "宣城市", "value": "宣城市" }
]
},
{
"label": "福建省",
"value": "福建省",
"children": [
{ "label": "福州市", "value": "福州市" },
{ "label": "厦门市", "value": "厦门市" },
{ "label": "莆田市", "value": "莆田市" },
{ "label": "三明市", "value": "三明市" },
{ "label": "泉州市", "value": "泉州市" },
{ "label": "漳州市", "value": "漳州市" },
{ "label": "南平市", "value": "南平市" },
{ "label": "龙岩市", "value": "龙岩市" },
{ "label": "宁德市", "value": "宁德市" }
]
},
{
"label": "江西省",
"value": "江西省",
"children": [
{ "label": "南昌市", "value": "南昌市" },
{ "label": "景德镇市", "value": "景德镇市" },
{ "label": "萍乡市", "value": "萍乡市" },
{ "label": "九江市", "value": "九江市" },
{ "label": "新余市", "value": "新余市" },
{ "label": "鹰潭市", "value": "鹰潭市" },
{ "label": "赣州市", "value": "赣州市" },
{ "label": "吉安市", "value": "吉安市" },
{ "label": "宜春市", "value": "宜春市" },
{ "label": "抚州市", "value": "抚州市" },
{ "label": "上饶市", "value": "上饶市" }
]
},
{
"label": "山东省",
"value": "山东省",
"children": [
{ "label": "济南市", "value": "济南市" },
{ "label": "青岛市", "value": "青岛市" },
{ "label": "淄博市", "value": "淄博市" },
{ "label": "枣庄市", "value": "枣庄市" },
{ "label": "东营市", "value": "东营市" },
{ "label": "烟台市", "value": "烟台市" },
{ "label": "潍坊市", "value": "潍坊市" },
{ "label": "济宁市", "value": "济宁市" },
{ "label": "泰安市", "value": "泰安市" },
{ "label": "威海市", "value": "威海市" },
{ "label": "日照市", "value": "日照市" },
{ "label": "临沂市", "value": "临沂市" },
{ "label": "德州市", "value": "德州市" },
{ "label": "聊城市", "value": "聊城市" },
{ "label": "滨州市", "value": "滨州市" },
{ "label": "菏泽市", "value": "菏泽市" }
]
},
{
"label": "河南省",
"value": "河南省",
"children": [
{ "label": "郑州市", "value": "郑州市" },
{ "label": "开封市", "value": "开封市" },
{ "label": "洛阳市", "value": "洛阳市" },
{ "label": "平顶山市", "value": "平顶山市" },
{ "label": "安阳市", "value": "安阳市" },
{ "label": "鹤壁市", "value": "鹤壁市" },
{ "label": "新乡市", "value": "新乡市" },
{ "label": "焦作市", "value": "焦作市" },
{ "label": "濮阳市", "value": "濮阳市" },
{ "label": "许昌市", "value": "许昌市" },
{ "label": "漯河市", "value": "漯河市" },
{ "label": "三门峡市", "value": "三门峡市" },
{ "label": "南阳市", "value": "南阳市" },
{ "label": "商丘市", "value": "商丘市" },
{ "label": "信阳市", "value": "信阳市" },
{ "label": "周口市", "value": "周口市" },
{ "label": "驻马店市", "value": "驻马店市" },
{ "label": "济源市", "value": "济源市" }
]
},
{
"label": "湖北省",
"value": "湖北省",
"children": [
{ "label": "武汉市", "value": "武汉市" },
{ "label": "黄石市", "value": "黄石市" },
{ "label": "十堰市", "value": "十堰市" },
{ "label": "宜昌市", "value": "宜昌市" },
{ "label": "襄阳市", "value": "襄阳市" },
{ "label": "鄂州市", "value": "鄂州市" },
{ "label": "荆门市", "value": "荆门市" },
{ "label": "孝感市", "value": "孝感市" },
{ "label": "荆州市", "value": "荆州市" },
{ "label": "黄冈市", "value": "黄冈市" },
{ "label": "咸宁市", "value": "咸宁市" },
{ "label": "随州市", "value": "随州市" },
{ "label": "恩施土家族苗族自治州", "value": "恩施土家族苗族自治州" },
{ "label": "仙桃市", "value": "仙桃市" },
{ "label": "潜江市", "value": "潜江市" },
{ "label": "天门市", "value": "天门市" },
{ "label": "神农架林区", "value": "神农架林区" }
]
},
{
"label": "湖南省",
"value": "湖南省",
"children": [
{ "label": "长沙市", "value": "长沙市" },
{ "label": "株洲市", "value": "株洲市" },
{ "label": "湘潭市", "value": "湘潭市" },
{ "label": "衡阳市", "value": "衡阳市" },
{ "label": "邵阳市", "value": "邵阳市" },
{ "label": "岳阳市", "value": "岳阳市" },
{ "label": "常德市", "value": "常德市" },
{ "label": "张家界市", "value": "张家界市" },
{ "label": "益阳市", "value": "益阳市" },
{ "label": "郴州市", "value": "郴州市" },
{ "label": "永州市", "value": "永州市" },
{ "label": "怀化市", "value": "怀化市" },
{ "label": "娄底市", "value": "娄底市" },
{ "label": "湘西土家族苗族自治州", "value": "湘西土家族苗族自治州" }
]
},
{
"label": "广东省",
"value": "广东省",
"children": [
{ "label": "广州市", "value": "广州市" },
{ "label": "韶关市", "value": "韶关市" },
{ "label": "深圳市", "value": "深圳市" },
{ "label": "珠海市", "value": "珠海市" },
{ "label": "汕头市", "value": "汕头市" },
{ "label": "佛山市", "value": "佛山市" },
{ "label": "江门市", "value": "江门市" },
{ "label": "湛江市", "value": "湛江市" },
{ "label": "茂名市", "value": "茂名市" },
{ "label": "肇庆市", "value": "肇庆市" },
{ "label": "惠州市", "value": "惠州市" },
{ "label": "梅州市", "value": "梅州市" },
{ "label": "汕尾市", "value": "汕尾市" },
{ "label": "河源市", "value": "河源市" },
{ "label": "阳江市", "value": "阳江市" },
{ "label": "清远市", "value": "清远市" },
{ "label": "东莞市", "value": "东莞市" },
{ "label": "中山市", "value": "中山市" },
{ "label": "潮州市", "value": "潮州市" },
{ "label": "揭阳市", "value": "揭阳市" },
{ "label": "云浮市", "value": "云浮市" }
]
},
{
"label": "广西壮族自治区",
"value": "广西壮族自治区",
"children": [
{ "label": "南宁市", "value": "南宁市" },
{ "label": "柳州市", "value": "柳州市" },
{ "label": "桂林市", "value": "桂林市" },
{ "label": "梧州市", "value": "梧州市" },
{ "label": "北海市", "value": "北海市" },
{ "label": "防城港市", "value": "防城港市" },
{ "label": "钦州市", "value": "钦州市" },
{ "label": "贵港市", "value": "贵港市" },
{ "label": "玉林市", "value": "玉林市" },
{ "label": "百色市", "value": "百色市" },
{ "label": "贺州市", "value": "贺州市" },
{ "label": "河池市", "value": "河池市" },
{ "label": "来宾市", "value": "来宾市" },
{ "label": "崇左市", "value": "崇左市" }
]
},
{
"label": "海南省",
"value": "海南省",
"children": [
{ "label": "海口市", "value": "海口市" },
{ "label": "三亚市", "value": "三亚市" },
{ "label": "三沙市", "value": "三沙市" },
{ "label": "儋州市", "value": "儋州市" },
{ "label": "五指山市", "value": "五指山市" },
{ "label": "琼海市", "value": "琼海市" },
{ "label": "文昌市", "value": "文昌市" },
{ "label": "万宁市", "value": "万宁市" },
{ "label": "东方市", "value": "东方市" }
]
},
{
"label": "重庆市",
"value": "重庆市",
"children": [
{ "label": "重庆市", "value": "重庆市" }
]
},
{
"label": "四川省",
"value": "四川省",
"children": [
{ "label": "成都市", "value": "成都市" },
{ "label": "自贡市", "value": "自贡市" },
{ "label": "攀枝花市", "value": "攀枝花市" },
{ "label": "泸州市", "value": "泸州市" },
{ "label": "德阳市", "value": "德阳市" },
{ "label": "绵阳市", "value": "绵阳市" },
{ "label": "广元市", "value": "广元市" },
{ "label": "遂宁市", "value": "遂宁市" },
{ "label": "内江市", "value": "内江市" },
{ "label": "乐山市", "value": "乐山市" },
{ "label": "南充市", "value": "南充市" },
{ "label": "眉山市", "value": "眉山市" },
{ "label": "宜宾市", "value": "宜宾市" },
{ "label": "广安市", "value": "广安市" },
{ "label": "达州市", "value": "达州市" },
{ "label": "雅安市", "value": "雅安市" },
{ "label": "巴中市", "value": "巴中市" },
{ "label": "资阳市", "value": "资阳市" },
{ "label": "阿坝藏族羌族自治州", "value": "阿坝藏族羌族自治州" },
{ "label": "甘孜藏族自治州", "value": "甘孜藏族自治州" },
{ "label": "凉山彝族自治州", "value": "凉山彝族自治州" }
]
},
{
"label": "贵州省",
"value": "贵州省",
"children": [
{ "label": "贵阳市", "value": "贵阳市" },
{ "label": "六盘水市", "value": "六盘水市" },
{ "label": "遵义市", "value": "遵义市" },
{ "label": "安顺市", "value": "安顺市" },
{ "label": "毕节市", "value": "毕节市" },
{ "label": "铜仁市", "value": "铜仁市" },
{ "label": "黔西南布依族苗族自治州", "value": "黔西南布依族苗族自治州" },
{ "label": "黔东南苗族侗族自治州", "value": "黔东南苗族侗族自治州" },
{ "label": "黔南布依族苗族自治州", "value": "黔南布依族苗族自治州" }
]
},
{
"label": "云南省",
"value": "云南省",
"children": [
{ "label": "昆明市", "value": "昆明市" },
{ "label": "曲靖市", "value": "曲靖市" },
{ "label": "玉溪市", "value": "玉溪市" },
{ "label": "保山市", "value": "保山市" },
{ "label": "昭通市", "value": "昭通市" },
{ "label": "丽江市", "value": "丽江市" },
{ "label": "普洱市", "value": "普洱市" },
{ "label": "临沧市", "value": "临沧市" },
{ "label": "楚雄彝族自治州", "value": "楚雄彝族自治州" },
{ "label": "红河哈尼族彝族自治州", "value": "红河哈尼族彝族自治州" },
{ "label": "文山壮族苗族自治州", "value": "文山壮族苗族自治州" },
{ "label": "西双版纳傣族自治州", "value": "西双版纳傣族自治州" },
{ "label": "大理白族自治州", "value": "大理白族自治州" },
{ "label": "德宏傣族景颇族自治州", "value": "德宏傣族景颇族自治州" },
{ "label": "怒江傈僳族自治州", "value": "怒江傈僳族自治州" },
{ "label": "迪庆藏族自治州", "value": "迪庆藏族自治州" }
]
},
{
"label": "西藏自治区",
"value": "西藏自治区",
"children": [
{ "label": "拉萨市", "value": "拉萨市" },
{ "label": "日喀则市", "value": "日喀则市" },
{ "label": "昌都市", "value": "昌都市" },
{ "label": "林芝市", "value": "林芝市" },
{ "label": "山南市", "value": "山南市" },
{ "label": "那曲市", "value": "那曲市" },
{ "label": "阿里地区", "value": "阿里地区" }
]
},
{
"label": "陕西省",
"value": "陕西省",
"children": [
{ "label": "西安市", "value": "西安市" },
{ "label": "铜川市", "value": "铜川市" },
{ "label": "宝鸡市", "value": "宝鸡市" },
{ "label": "咸阳市", "value": "咸阳市" },
{ "label": "渭南市", "value": "渭南市" },
{ "label": "延安市", "value": "延安市" },
{ "label": "汉中市", "value": "汉中市" },
{ "label": "榆林市", "value": "榆林市" },
{ "label": "安康市", "value": "安康市" },
{ "label": "商洛市", "value": "商洛市" }
]
},
{
"label": "甘肃省",
"value": "甘肃省",
"children": [
{ "label": "兰州市", "value": "兰州市" },
{ "label": "嘉峪关市", "value": "嘉峪关市" },
{ "label": "金昌市", "value": "金昌市" },
{ "label": "白银市", "value": "白银市" },
{ "label": "天水市", "value": "天水市" },
{ "label": "武威市", "value": "武威市" },
{ "label": "张掖市", "value": "张掖市" },
{ "label": "平凉市", "value": "平凉市" },
{ "label": "酒泉市", "value": "酒泉市" },
{ "label": "庆阳市", "value": "庆阳市" },
{ "label": "定西市", "value": "定西市" },
{ "label": "陇南市", "value": "陇南市" },
{ "label": "临夏回族自治州", "value": "临夏回族自治州" },
{ "label": "甘南藏族自治州", "value": "甘南藏族自治州" }
]
},
{
"label": "青海省",
"value": "青海省",
"children": [
{ "label": "西宁市", "value": "西宁市" },
{ "label": "海东市", "value": "海东市" },
{ "label": "海北藏族自治州", "value": "海北藏族自治州" },
{ "label": "黄南藏族自治州", "value": "黄南藏族自治州" },
{ "label": "海南藏族自治州", "value": "海南藏族自治州" },
{ "label": "果洛藏族自治州", "value": "果洛藏族自治州" },
{ "label": "玉树藏族自治州", "value": "玉树藏族自治州" },
{ "label": "海西蒙古族藏族自治州", "value": "海西蒙古族藏族自治州" }
]
},
{
"label": "宁夏回族自治区",
"value": "宁夏回族自治区",
"children": [
{ "label": "银川市", "value": "银川市" },
{ "label": "石嘴山市", "value": "石嘴山市" },
{ "label": "吴忠市", "value": "吴忠市" },
{ "label": "固原市", "value": "固原市" },
{ "label": "中卫市", "value": "中卫市" }
]
},
{
"label": "新疆维吾尔自治区",
"value": "新疆维吾尔自治区",
"children": [
{ "label": "乌鲁木齐市", "value": "乌鲁木齐市" },
{ "label": "克拉玛依市", "value": "克拉玛依市" },
{ "label": "吐鲁番市", "value": "吐鲁番市" },
{ "label": "哈密市", "value": "哈密市" },
{ "label": "昌吉回族自治州", "value": "昌吉回族自治州" },
{ "label": "博尔塔拉蒙古自治州", "value": "博尔塔拉蒙古自治州" },
{ "label": "巴音郭楞蒙古自治州", "value": "巴音郭楞蒙古自治州" },
{ "label": "阿克苏地区", "value": "阿克苏地区" },
{ "label": "克孜勒苏柯尔克孜自治州", "value": "克孜勒苏柯尔克孜自治州" },
{ "label": "喀什地区", "value": "喀什地区" },
{ "label": "和田地区", "value": "和田地区" },
{ "label": "伊犁哈萨克自治州", "value": "伊犁哈萨克自治州" },
{ "label": "塔城地区", "value": "塔城地区" },
{ "label": "阿勒泰地区", "value": "阿勒泰地区" },
{ "label": "石河子市", "value": "石河子市" },
{ "label": "阿拉尔市", "value": "阿拉尔市" },
{ "label": "图木舒克市", "value": "图木舒克市" },
{ "label": "五家渠市", "value": "五家渠市" },
{ "label": "北屯市", "value": "北屯市" },
{ "label": "铁门关市", "value": "铁门关市" },
{ "label": "双河市", "value": "双河市" },
{ "label": "可克达拉市", "value": "可克达拉市" },
{ "label": "昆玉市", "value": "昆玉市" },
{ "label": "胡杨河市", "value": "胡杨河市" },
{ "label": "新星市", "value": "新星市" },
{ "label": "白杨市", "value": "白杨市" }
]
},
{
"label": "台湾省",
"value": "台湾省",
"children": [
{ "label": "台北市", "value": "台北市" },
{ "label": "新北市", "value": "新北市" },
{ "label": "桃园市", "value": "桃园市" },
{ "label": "台中市", "value": "台中市" },
{ "label": "台南市", "value": "台南市" },
{ "label": "高雄市", "value": "高雄市" },
{ "label": "基隆市", "value": "基隆市" },
{ "label": "新竹市", "value": "新竹市" },
{ "label": "嘉义市", "value": "嘉义市" },
{ "label": "新竹县", "value": "新竹县" },
{ "label": "苗栗县", "value": "苗栗县" },
{ "label": "彰化县", "value": "彰化县" },
{ "label": "南投县", "value": "南投县" },
{ "label": "云林县", "value": "云林县" },
{ "label": "嘉义县", "value": "嘉义县" },
{ "label": "屏东县", "value": "屏东县" },
{ "label": "宜兰县", "value": "宜兰县" },
{ "label": "花莲县", "value": "花莲县" },
{ "label": "台东县", "value": "台东县" },
{ "label": "澎湖县", "value": "澎湖县" },
{ "label": "金门县", "value": "金门县" },
{ "label": "连江县", "value": "连江县" }
]
},
{
"label": "香港特别行政区",
"value": "香港特别行政区",
"children": [
{ "label": "香港特别行政区", "value": "香港特别行政区" }
]
},
{
"label": "澳门特别行政区",
"value": "澳门特别行政区",
"children": [
{ "label": "澳门特别行政区", "value": "澳门特别行政区" }
]
}
]
+775
View File
@@ -0,0 +1,775 @@
<template>
<el-dialog v-model="visible" title="系统配置" width="80%" top="5vh" class="dialog-60h" @closed="handleClose">
<el-tabs v-model="activeTab" class="dialog-tabs">
<el-tab-pane label="模型配置" name="model">
<div class="tab-toolbar">
<el-input v-model="modelFilters.keyword" placeholder="模型名称" clearable style="width:140px" @clear="searchModelList" />
<el-select v-model="modelFilters.modelType" placeholder="全部类型" clearable style="width:130px" @change="searchModelList">
<el-option label="推理模型" value="chat" />
<el-option label="视频生成模型" value="video" />
</el-select>
<el-button type="primary" size="small" @click="searchModelList">搜索</el-button>
<el-button size="small" @click="resetModelSearch">重置</el-button>
<span class="search-spacer"></span>
<el-button type="primary" size="small" @click="openAddModel">
<el-icon :size="14"><Plus /></el-icon> 添加模型
</el-button>
</div>
<div class="table-wrap">
<el-table :data="modelList" stripe v-loading="loading" height="100%">
<el-table-column label="模型类别" width="120">
<template #default="{ row }">
<el-tag :type="row.modelType === 'chat' ? 'primary' : 'success'" size="small">
{{ row.modelType === 'chat' ? '推理模型' : '视频生成模型' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="模型名称" prop="modelName" min-width="120" />
<el-table-column label="单价" width="90">
<template #default="{ row }">
{{ ((row.price || 0) / 100).toFixed(2) }}
</template>
</el-table-column>
<el-table-column label="计价单位" width="110">
<template #default="{ row }">
{{ priceUnitLabel(row.priceUnit) }}
</template>
</el-table-column>
<el-table-column label="并发数" width="80" prop="concurrencyCount" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEditModel(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="支付配置" name="payment">
<div style="margin-bottom:12px;">
<el-button type="primary" size="small" @click="openAddPayment">
<el-icon :size="14"><Plus /></el-icon> 添加配置
</el-button>
</div>
<el-table :data="paymentChannelList" stripe>
<el-table-column label="支付渠道" prop="channelLabel" width="100" />
<el-table-column label="支付类型" prop="typeLabel" width="90" />
<el-table-column label="配置信息" min-width="200">
<template #default="{ row }">
{{ row.configInfo || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button size="small" @click="openEditPayment(row.key)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="地区定价" name="regionPricing">
<div class="tab-toolbar">
<el-input v-model="rpFilters.keyword" placeholder="地区名称" clearable style="width:140px" @clear="searchRegionPricing" />
<el-select v-model="rpFilters.protected" placeholder="全部类型" clearable style="width:120px" @change="searchRegionPricing">
<el-option label="普通" :value="0" />
<el-option label="受保护" :value="1" />
</el-select>
<el-button type="primary" size="small" @click="searchRegionPricing">搜索</el-button>
<el-button size="small" @click="resetRpSearch">重置</el-button>
<span class="search-spacer"></span>
<el-button type="primary" size="small" @click="handleAddRegionPricing">
<el-icon :size="14"><Plus /></el-icon> 添加定价
</el-button>
</div>
<div class="table-wrap">
<el-table :data="regionPricingList" stripe v-loading="regionPricingLoading" height="100%">
<el-table-column prop="region" label="地区" />
<el-table-column label="类型" width="100">
<template #default="{ row }"><el-tag :type="row.protected ? 'warning' : 'info'" size="small">{{ row.protected ? '受保护' : '普通' }}</el-tag></template>
</el-table-column>
<el-table-column label="年费" width="150">
<template #default="{ row }">{{ (row.price / 100).toFixed(2) }} </template>
</el-table-column>
<el-table-column prop="max_customers" label="客户数上限" width="120" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleEditRegionPricing(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDeleteRegionPricing(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
<div v-if="activeTab === 'model' && modelTotal > 0" class="dialog-pagination">
<el-pagination v-model:current-page="modelPage" :page-size="modelPageSize" :total="modelTotal" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" background @current-change="fetchModelList" @size-change="onModelSizeChange" small />
</div>
<div v-if="activeTab === 'regionPricing' && rpTotal > 0" class="dialog-pagination">
<el-pagination v-model:current-page="rpPage" :page-size="rpPageSize" :total="rpTotal" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" background @current-change="fetchRegionPricing" @size-change="onRpSizeChange" small />
</div>
<!-- 模型编辑对话框 -->
<el-dialog v-model="modelEditorVisible" :title="modelEditorTitle" width="55%" top="8vh" destroy-on-close @closed="resetModelForm">
<el-form :model="modelForm" label-position="top" size="small">
<el-row :gutter="16">
<el-col :xs="24" :sm="12">
<el-form-item label="模型类别" required>
<el-select v-model="modelForm.modelType" style="width:100%" placeholder="请选择模型类别">
<el-option label="推理模型" value="chat" />
<el-option label="视频生成模型" value="video" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="模型名称" required>
<el-input v-model="modelForm.modelName" placeholder="qwen-max" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="单价">
<el-input-number v-model="modelForm.price" :min="0" :step="0.01" :precision="2" style="width:100%" />
<div class="el-form-item__tip">视频生成时按此价格扣费</div>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="计价单位">
<el-select v-model="modelForm.priceUnit" style="width:100%">
<el-option v-for="opt in priceUnitOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="并发数量限制">
<el-input-number v-model="modelForm.concurrencyCount" :min="1" :max="100" style="width:100%" />
<div class="el-form-item__tip">同一模型最多同时处理的请求数量</div>
</el-form-item>
</el-col>
<el-col :xs="24">
<el-form-item label="Schema">
<div class="schema-config-row">
<el-button size="small" :type="modelForm.schema ? 'primary' : 'default'" @click="openSchemaEditor">
<el-icon :size="14"><Setting /></el-icon> 配置 Schema
</el-button>
<span v-if="modelForm.schema" class="schema-status schema-status-ok">已配置</span>
<span v-else class="schema-status schema-status-empty">未配置</span>
</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="modelEditorVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSaveModel">保存</el-button>
</template>
</el-dialog>
<!-- Schema JSON 编辑器 -->
<el-dialog v-model="schemaEditorVisible" title="Schema 配置" width="75%" top="5vh" destroy-on-close>
<div class="schema-toolbar">
<el-button size="small" @click="schemaEditorRef?.openPasteDialog()">
<el-icon :size="14"><CopyDocument /></el-icon> 粘贴 JSON
</el-button>
<el-button size="small" @click="previewSchemaJson">
<el-icon :size="14"><View /></el-icon> 查看 JSON
</el-button>
</div>
<div class="schema-editor-body">
<JsonEditor ref="schemaEditorRef" v-model="schemaData" />
</div>
<template #footer>
<el-button @click="schemaEditorVisible = false">取消</el-button>
<el-button type="primary" @click="saveSchema">确定</el-button>
</template>
</el-dialog>
<!-- JSON 预览弹窗 -->
<el-dialog v-model="schemaPreviewVisible" title="Schema 预览" width="60%" top="8vh" destroy-on-close>
<el-input v-model="schemaPreviewText" type="textarea" :rows="16" readonly />
</el-dialog>
<!-- 支付配置编辑对话框 -->
<el-dialog v-model="paymentEditorVisible" :title="paymentEditorTitle" width="55%" top="8vh" destroy-on-close @closed="paymentEditorForm.channel = ''">
<el-form :model="paymentEditorForm" label-width="120px" label-position="top" size="small">
<el-form-item label="支付渠道" required>
<el-select v-model="paymentEditorForm.channel" :disabled="!!editingChannel" style="width:100%" placeholder="请选择支付渠道">
<el-option label="微信支付" value="wechat" />
<el-option label="支付宝" value="alipay" />
<el-option label="线下支付" value="offline" />
</el-select>
</el-form-item>
<el-form-item label="支付类型" required v-if="paymentEditorForm.channel && paymentEditorForm.channel !== 'offline'">
<el-select v-model="paymentEditorForm.channelType" :disabled="!!editingChannel" style="width:100%" placeholder="请选择支付类型">
<el-option v-for="opt in channelTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<template v-if="paymentEditorForm.channel === 'wechat'">
<el-row :gutter="16">
<el-col :xs="24" :sm="12">
<el-form-item label="AppId">
<el-input v-model="paymentEditorForm.appId" placeholder="微信应用ID" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="商户号(MchId)">
<el-input v-model="paymentEditorForm.mchId" placeholder="微信商户号" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="API密钥">
<el-input v-model="paymentEditorForm.apiKey" type="password" placeholder="API Key" show-password />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="AppSecret">
<el-input v-model="paymentEditorForm.appSecret" type="password" placeholder="AppSecret" show-password />
</el-form-item>
</el-col>
</el-row>
</template>
<template v-else-if="paymentEditorForm.channel === 'alipay'">
<el-row :gutter="16">
<el-col :xs="24" :sm="12">
<el-form-item label="AppId">
<el-input v-model="paymentEditorForm.appId" placeholder="支付宝应用ID" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="应用私钥">
<el-input v-model="paymentEditorForm.privateKey" type="password" placeholder="RSA2 应用私钥" show-password />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="支付宝公钥">
<el-input v-model="paymentEditorForm.publicKey" type="password" placeholder="支付宝公钥" show-password />
</el-form-item>
</el-col>
</el-row>
</template>
<template v-else-if="paymentEditorForm.channel === 'offline'">
<p style="color:#909399;font-size:13px;margin:0">线下支付不需要额外配置启用后用户在充值/续费时可以选择此方式由管理员线下确认收款</p>
</template>
</el-form>
<template #footer>
<el-button @click="paymentEditorVisible = false">取消</el-button>
<el-button type="primary" @click="savePaymentChannel">保存</el-button>
</template>
</el-dialog>
<!-- 地区定价编辑对话框 -->
<el-dialog v-model="rpDialogVisible" :title="rpIsEdit ? '编辑定价' : '添加定价'" width="500px" destroy-on-close>
<el-form ref="rpFormRef" :model="rpForm" :rules="rpRules" label-width="120px">
<el-form-item label="省份/城市" prop="region">
<el-cascader v-model="rpRegionCascade" :options="chinaRegions" :props="{ label: 'label', value: 'value', children: 'children', expandTrigger: 'hover' }" placeholder="请选择省/直辖市 → 城市/区" style="width:100%" clearable filterable @change="onRegionCascadeChange" />
</el-form-item>
<el-form-item label="类型">
<el-switch v-model="rpForm.protected" active-text="受保护" inactive-text="普通" />
</el-form-item>
<el-form-item label="年费(元)" prop="price"><el-input-number v-model="rpForm.price" :min="0" :step="100" style="width:100%" /></el-form-item>
<el-form-item label="客户数上限"><el-input-number v-model="rpForm.max_customers" :min="0" style="width:100%" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="rpDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="rpSaving" @click="handleSaveRegionPricing">保存</el-button>
</template>
</el-dialog>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Setting, CopyDocument, View, Plus } from '@element-plus/icons-vue'
import { getModelConfig, saveModelConfig, getPaymentConfig, savePaymentConfig } from '@/api/config'
import { listRegionPricing, saveRegionPricing, deleteRegionPricing } from '@/api/regionPricing'
import { JsonEditor } from 'json-schema-editor'
import 'json-schema-editor/dist/style.css'
import chinaRegions from '@/assets/china-regions.json'
const props = defineProps({
modelValue: Boolean
})
const emit = defineEmits(['update:modelValue'])
const visible = ref(false)
const activeTab = ref('model')
const loading = ref(false)
const saving = ref(false)
// 模型列表(分页)
const modelList = ref([])
const modelPage = ref(1)
const modelPageSize = ref(20)
const modelTotal = ref(0)
const modelFilters = ref({ keyword: '', modelType: '' })
// 模型编辑对话框
const modelEditorVisible = ref(false)
const isEditingModel = ref(false)
const editingModelId = ref(0)
const modelEditorTitle = computed(() => isEditingModel.value ? '编辑模型配置' : '添加模型配置')
// 根据模型类别返回对应的计价单位选项
const priceUnitOptions = computed(() => {
if (modelForm.modelType === 'chat') {
return [
{ label: '百万token', value: 'per_million_tokens' },
{ label: '千万token', value: 'per_ten_million_tokens' },
]
}
return [
{ label: '每秒', value: 'second' },
]
})
const modelForm = reactive({
modelType: 'chat',
modelName: '',
schema: null,
price: 0,
priceUnit: 'per_million_tokens',
concurrencyCount: 10,
})
function resetModelForm() {
modelForm.modelType = 'chat'
modelForm.modelName = ''
modelForm.price = 0
modelForm.priceUnit = 'per_million_tokens'
modelForm.concurrencyCount = 10
modelForm.schema = null
isEditingModel.value = false
editingModelId.value = 0
}
function openAddModel() {
resetModelForm()
modelEditorVisible.value = true
}
function openEditModel(row) {
isEditingModel.value = true
editingModelId.value = row.id
suppressModelTypeWatch = true
modelForm.modelType = row.modelType || 'chat'
modelForm.modelName = row.modelName || ''
modelForm.price = (row.price || 0) / 100
modelForm.priceUnit = row.priceUnit || 'second'
modelForm.concurrencyCount = row.concurrencyCount || (modelForm.modelType === 'chat' ? 10 : 3)
// 表中可能是旧值,根据模型类别选择合适的单位
if (modelForm.modelType === 'chat') {
if (row.priceUnit !== 'per_million_tokens' && row.priceUnit !== 'per_ten_million_tokens') {
modelForm.priceUnit = 'per_million_tokens'
} else {
modelForm.priceUnit = row.priceUnit || 'per_million_tokens'
}
} else {
modelForm.priceUnit = (row.priceUnit === 'second') ? row.priceUnit : 'second'
}
suppressModelTypeWatch = false
modelForm.schema = tryParseSchema(row.schema)
modelEditorVisible.value = true
}
const priceUnits = { per_million_tokens: '百万token', per_ten_million_tokens: '千万token', second: '每秒', video: '每次视频' }
function priceUnitLabel(val) { return priceUnits[val] || val || '-' }
// 监听模型类别变化,自动调整并发默认值和计价单位
let suppressModelTypeWatch = false
watch(() => modelForm.modelType, (val) => {
if (suppressModelTypeWatch) return
modelForm.concurrencyCount = val === 'chat' ? 10 : 3
modelForm.priceUnit = val === 'chat' ? 'per_million_tokens' : 'second'
})
// Schema JSON 编辑器
const schemaEditorVisible = ref(false)
const schemaData = ref(null)
const schemaEditorRef = ref(null)
function openSchemaEditor() {
schemaData.value = modelForm.schema || {}
schemaEditorVisible.value = true
}
function saveSchema() {
modelForm.schema = schemaData.value
schemaEditorVisible.value = false
}
// JSON 预览
const schemaPreviewVisible = ref(false)
const schemaPreviewText = ref('')
function previewSchemaJson() {
schemaPreviewText.value = JSON.stringify(schemaData.value, null, 2)
schemaPreviewVisible.value = true
}
/** 兼容处理:后端可能返回字符串或对象 */
function tryParseSchema(val) {
if (!val) return null
if (typeof val === 'string') {
try { return JSON.parse(val) } catch { return null }
}
return val
}
// 支付配置
const paymentChannelList = ref([])
const paymentEditorVisible = ref(false)
const editingChannel = ref('')
const paymentEditorForm = reactive({
channel: '',
channelType: '',
appId: '',
mchId: '',
apiKey: '',
appSecret: '',
privateKey: '',
publicKey: ''
})
const paymentEditorTitle = computed(() => editingChannel.value ? '编辑支付配置' : '添加支付配置')
const channelTypeOptions = computed(() => {
const map = {
wechat: [
{ value: 'jsapi', label: 'JSAPI(公众号/服务号)' },
{ value: 'h5', label: 'H5(手机网页)' },
{ value: 'app', label: 'APP(移动应用)' },
{ value: 'native', label: 'NativePC扫码)' }
],
alipay: [
{ value: 'jsapi', label: 'JSAPI(支付宝小程序)' },
{ value: 'h5', label: 'H5(手机网页)' },
{ value: 'app', label: 'APP(移动应用)' },
{ value: 'precreate', label: 'PrecreatePC扫码)' }
]
}
return map[paymentEditorForm.channel] || []
})
function buildPaymentChannelList(pc) {
const list = []
const channelLabels = { wechat: '微信支付', alipay: '支付宝', offline: '线下支付' }
const typeLabels = { jsapi: 'JSAPI', h5: 'H5', app: 'APP', native: 'Native', precreate: 'Precreate', manual: '手动' }
const channels = ['wechat', 'alipay']
for (const ch of channels) {
const sub = pc?.[ch]
if (!sub) continue
for (const [type, cfg] of Object.entries(sub)) {
if (!cfg) continue
const item = {
key: ch + ':' + type,
channel: ch,
channelType: type,
channelLabel: channelLabels[ch] || ch,
typeLabel: typeLabels[type] || type,
configInfo: cfg.appId ? 'AppId: ' + cfg.appId : '已启用',
appId: cfg.appId || '',
mchId: cfg.mchId || '',
apiKey: cfg.apiKey || '',
appSecret: cfg.appSecret || '',
privateKey: cfg.privateKey || '',
publicKey: cfg.publicKey || ''
}
list.push(item)
}
}
if (pc?.offline) {
list.push({
key: 'offline:manual',
channel: 'offline',
channelType: 'manual',
channelLabel: channelLabels.offline,
typeLabel: typeLabels.manual,
configInfo: '已启用',
appId: '', mchId: '', apiKey: '', appSecret: '', privateKey: '', publicKey: ''
})
}
return list
}
function openAddPayment() {
editingChannel.value = ''
Object.assign(paymentEditorForm, { channel: '', channelType: '', appId: '', mchId: '', apiKey: '', appSecret: '', privateKey: '', publicKey: '' })
paymentEditorVisible.value = true
}
function openEditPayment(key) {
editingChannel.value = key
const cfg = paymentChannelList.value.find(c => c.key === key)
if (cfg) {
paymentEditorForm.channel = cfg.channel
paymentEditorForm.channelType = cfg.channelType || ''
paymentEditorForm.appId = cfg.appId || ''
paymentEditorForm.mchId = cfg.mchId || ''
paymentEditorForm.apiKey = cfg.apiKey || ''
paymentEditorForm.appSecret = cfg.appSecret || ''
paymentEditorForm.privateKey = cfg.privateKey || ''
paymentEditorForm.publicKey = cfg.publicKey || ''
}
paymentEditorVisible.value = true
}
async function savePaymentChannel() {
if (!paymentEditorForm.channel) { ElMessage.warning('请选择支付渠道'); return }
if (paymentEditorForm.channel !== 'offline' && !paymentEditorForm.channelType) { ElMessage.warning('请选择支付类型'); return }
const data = {
channel: paymentEditorForm.channel,
channelType: paymentEditorForm.channelType
}
if (data.channel === 'wechat') {
Object.assign(data, {
appId: paymentEditorForm.appId,
mchId: paymentEditorForm.mchId,
apiKey: paymentEditorForm.apiKey,
appSecret: paymentEditorForm.appSecret
})
} else if (data.channel === 'alipay') {
Object.assign(data, {
appId: paymentEditorForm.appId,
privateKey: paymentEditorForm.privateKey,
publicKey: paymentEditorForm.publicKey
})
}
try {
await savePaymentConfig(data)
ElMessage.success('支付配置已保存')
paymentEditorVisible.value = false
const pc = await getPaymentConfig()
paymentChannelList.value = buildPaymentChannelList(pc)
} catch (e) {
// handled by interceptor
}
}
// 地区定价(分页)
const regionPricingList = ref([])
const regionPricingLoading = ref(false)
const rpPage = ref(1)
const rpPageSize = ref(20)
const rpTotal = ref(0)
const rpFilters = ref({ keyword: '', protected: '' })
const rpDialogVisible = ref(false)
const rpSaving = ref(false)
const rpIsEdit = ref(false)
const rpFormRef = ref(null)
const rpForm = ref({ province: '', region: '', protected: false, price: 0, max_customers: 0 })
const rpRules = { price: [{ required: true, message: '必填', trigger: 'blur' }] }
const rpRegionCascade = ref([])
function onRegionCascadeChange(val) {
if (val && val.length === 2) {
rpForm.value.province = val[0]
rpForm.value.region = val[1]
} else {
rpForm.value.province = ''
rpForm.value.region = ''
}
}
async function fetchRegionPricing() {
regionPricingLoading.value = true
try {
const params = { page: rpPage.value, pageSize: rpPageSize.value }
if (rpFilters.value.keyword) params.keyword = rpFilters.value.keyword
if (rpFilters.value.protected !== '' && rpFilters.value.protected != null) params.protected = Number(rpFilters.value.protected)
const r = await listRegionPricing(params)
regionPricingList.value = r.list || []
rpTotal.value = r.total || 0
} finally { regionPricingLoading.value = false }
}
function searchRegionPricing() { rpPage.value = 1; fetchRegionPricing() }
function resetRpSearch() { rpFilters.value = { keyword: '', protected: '' }; rpPage.value = 1; fetchRegionPricing() }
function onRpSizeChange(size) { rpPageSize.value = size; rpPage.value = 1; fetchRegionPricing() }
// 模型列表分页
async function fetchModelList() {
loading.value = true
try {
const params = { page: modelPage.value, pageSize: modelPageSize.value }
if (modelFilters.value.keyword) params.keyword = modelFilters.value.keyword
if (modelFilters.value.modelType) params.modelType = modelFilters.value.modelType
const res = await getModelConfig(params)
modelList.value = res.list || []
modelTotal.value = res.total || 0
} catch (e) { /* ignore */ }
finally { loading.value = false }
}
function searchModelList() { modelPage.value = 1; fetchModelList() }
function resetModelSearch() { modelFilters.value = { keyword: '', modelType: '' }; modelPage.value = 1; fetchModelList() }
function onModelSizeChange(size) { modelPageSize.value = size; modelPage.value = 1; fetchModelList() }
function handleAddRegionPricing() {
rpIsEdit.value = false
rpForm.value = { province: '', region: '', protected: false, price: 0, max_customers: 0 }
rpRegionCascade.value = []
rpDialogVisible.value = true
}
function handleEditRegionPricing(row) {
rpIsEdit.value = true
rpForm.value = { id: row.id, province: row.province || '', region: row.region, protected: !!row.protected, price: row.price / 100, max_customers: row.max_customers }
rpRegionCascade.value = (row.province && row.region) ? [row.province, row.region] : []
rpDialogVisible.value = true
}
async function handleSaveRegionPricing() {
if (rpRegionCascade.value.length !== 2) { ElMessage.warning('请选择省份和城市'); return }
rpSaving.value = true
try {
const d = { ...rpForm.value, price: Math.round(Number(rpForm.value.price) * 100), protected: rpForm.value.protected ? 1 : 0 }
await saveRegionPricing(d)
ElMessage.success('保存成功')
rpDialogVisible.value = false; await fetchRegionPricing()
} finally { rpSaving.value = false }
}
function handleDeleteRegionPricing(row) {
ElMessageBox.confirm('确定删除该地区定价?', '提示').then(async () => { await deleteRegionPricing(row.id); ElMessage.success('删除成功'); await fetchRegionPricing() }).catch(() => {})
}
watch(() => props.modelValue, async (val) => {
visible.value = val
if (val) {
await loadConfig()
}
})
function handleClose() {
emit('update:modelValue', false)
}
async function loadConfig() {
// 模型配置(分页)
modelPage.value = 1
modelPageSize.value = 20
modelFilters.value = { keyword: '', modelType: '' }
await fetchModelList()
// 支付配置从独立表加载
try {
const pc = await getPaymentConfig()
paymentChannelList.value = buildPaymentChannelList(pc)
} catch (e) {
// ignore
}
// 地区定价(分页)
rpPage.value = 1
rpPageSize.value = 20
rpFilters.value = { keyword: '', protected: '' }
try { await fetchRegionPricing() } catch (e) { /* ignore */ }
}
async function handleSaveModel() {
if (!modelForm.modelType) { ElMessage.warning('请选择模型类别'); return }
if (!modelForm.modelName) { ElMessage.warning('请输入模型名称'); return }
saving.value = true
try {
const data = {
modelType: modelForm.modelType,
modelName: modelForm.modelName,
schema: modelForm.schema,
price: Math.round(modelForm.price * 100),
priceUnit: modelForm.priceUnit || 'second',
concurrencyCount: modelForm.concurrencyCount,
}
if (isEditingModel.value && editingModelId.value > 0) {
data.id = editingModelId.value
}
await saveModelConfig(data)
ElMessage.success('模型配置已保存')
modelEditorVisible.value = false
await fetchModelList()
} catch (e) {
// error already handled by interceptor
} finally {
saving.value = false
}
}
</script>
<style scoped>
.el-dialog :deep(.el-dialog__body) {
display: flex;
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
padding: 16px 20px 0;
}
.dialog-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dialog-tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
overflow: hidden;
display: grid;
grid-template-rows: 1fr;
grid-template-columns: 1fr;
}
.dialog-tabs :deep(.el-tab-pane) {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.table-wrap {
flex: 1;
min-height: 0;
overflow: hidden;
}
.dialog-pagination {
flex-shrink: 0;
margin-top: auto;
display: flex;
justify-content: center;
padding: 10px 20px;
border-top: 1px solid #e5e5e5;
background: #fff;
}
.schema-config-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.schema-config-row .schema-status {
font-size: 12px;
}
.schema-config-row .schema-status-ok {
color: var(--el-color-success, #67c23a);
}
.schema-config-row .schema-status-empty {
color: var(--el-text-color-secondary, #909399);
}
.schema-toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.schema-editor-body {
height: 65vh;
border: 1px solid var(--el-border-color-light, #e4e7ed);
border-radius: 4px;
padding: 4px;
}
.schema-editor-body :deep(.json-editor-split) {
height: 100%;
}
.tab-toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-shrink: 0;
flex-wrap: wrap;
align-items: center;
}
.search-spacer { flex: 1; }
</style>
+860
View File
@@ -0,0 +1,860 @@
<template>
<el-dialog v-model="visible" :title="isEdit ? '编辑项目' : '新建项目'" width="80%" top="5vh" class="dialog-60h drama-edit-dialog" @closed="handleClose" destroy-on-close>
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="基础信息" name="basic">
<div class="basic-tab-scroll">
<div style="max-width:400px;margin:0 auto;padding:20px 0">
<el-form :model="form" label-position="top" size="small">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入标题" />
</el-form-item>
<!-- 单集时长 -->
<el-form-item label="时长(秒)" required>
<el-input-number v-model="form.episodeDuration" :min="10" :max="600" style="width:100%" />
</el-form-item>
<el-form-item label="画面比例" required>
<el-select v-model="form.aspectRatio" style="width:100%" placeholder="请选择画面比例" clearable>
<el-option label="9:16竖屏" value="9:16竖屏" />
<el-option label="16:9横屏" value="16:9横屏" />
<el-option label="1:1方形" value="1:1方形" />
</el-select>
</el-form-item>
<el-form-item label="分辨率" required>
<el-select v-model="form.resolution" style="width:100%" placeholder="请选择分辨率" clearable>
<el-option label="720P" value="720P" />
<el-option label="1080P" value="1080P" />
</el-select>
</el-form-item>
<!-- 类型选择创建时可选编辑时不可更改 -->
<el-form-item label="内容类型" required>
<el-select v-model="form.type" style="width:100%" :disabled="isEdit" @change="onTypeChange">
<el-option v-for="t in contentTypes" :key="t" :label="t" :value="t" />
</el-select>
</el-form-item>
<!-- 类型专属字段动态渲染 -->
<template v-if="currentFields.length > 0">
<el-form-item v-for="fd in currentFields" :key="fd.key" :label="fd.label" :required="fd.required">
<el-select v-model="form.config[fd.key]" style="width:100%" :placeholder="'请选择' + fd.label" :multiple="!!fd.multi" :collapse-tags="!!fd.multi">
<el-option v-for="opt in fd.options" :key="opt" :label="opt" :value="opt" />
</el-select>
</el-form-item>
</template>
</el-form>
<div style="text-align:center;margin-top:20px">
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</div>
</div>
</div>
</el-tab-pane>
<!-- 演员/角色 -->
<el-tab-pane label="演员" name="actor">
<div class="sub-toolbar">
<el-input v-model="charSearch" placeholder="搜索演员..." clearable size="small" style="width:200px" />
<el-button size="small" type="primary" @click="openCharForm()">+ 添加演员</el-button>
</div>
<div v-if="characters.length === 0" style="color:#909399;font-size:13px;padding:8px 0;text-align:center">
暂无演员点击上方按钮添加
</div>
<div v-else-if="filteredChars.length === 0" style="color:#909399;font-size:13px;padding:8px 0;text-align:center">
未找到匹配的演员
</div>
<div v-else class="sub-list">
<div v-for="(c, i) in paginatedChars" :key="c.id" class="sub-item">
<el-avatar v-if="c.portraitPath" :src="c.portraitPath" :size="36" />
<el-avatar v-else :size="36">{{ c.name ? c.name.charAt(0) : '?' }}</el-avatar>
<div class="sub-info">
<strong>{{ c.name }}</strong>
<span v-if="c.voicePath" style="color:#909399;font-size:12px"> · {{ c.voicePath.split('/').pop() }}</span>
<div v-if="c.description" style="color:#909399;font-size:11px">{{ c.description }}</div>
</div>
<div class="sub-actions">
<el-button size="small" text @click="openCharForm(c, i)">编辑</el-button>
<el-button size="small" text type="danger" @click="handleDeleteChar(c, i)">删除</el-button>
</div>
</div>
</div>
<div v-if="filteredChars.length > pageSize" style="display:flex;justify-content:center;margin-top:8px">
<el-pagination v-model:current-page="charPage" :page-size="pageSize" :total="filteredChars.length" layout="prev, pager, next" small />
</div>
</el-tab-pane>
<el-tab-pane label="场景" name="scene">
<div class="sub-toolbar">
<el-input v-model="sceneSearch" placeholder="搜索场景..." clearable size="small" style="width:200px" />
<el-button size="small" type="primary" @click="openSceneForm()">+ 添加场景</el-button>
</div>
<div v-if="scenes.length === 0" style="color:#909399;font-size:13px;padding:8px 0;text-align:center">
暂无场景点击上方按钮添加
</div>
<div v-else-if="filteredScenes.length === 0" style="color:#909399;font-size:13px;padding:8px 0;text-align:center">
未找到匹配的场景
</div>
<div v-else class="sub-list">
<div v-for="(s, i) in paginatedScenes" :key="s.id" class="sub-item">
<el-avatar v-if="s.imagePath" :src="s.imagePath" shape="square" :size="36" />
<el-avatar v-else shape="square" :size="36">{{ s.name ? s.name.charAt(0) : '?' }}</el-avatar>
<div class="sub-info">
<strong>{{ s.name }}</strong>
<div v-if="s.description" style="color:#909399;font-size:11px">{{ s.description }}</div>
</div>
<div class="sub-actions">
<el-button size="small" text @click="openSceneForm(s, i)">编辑</el-button>
<el-button size="small" text type="danger" @click="handleDeleteScene(s, i)">删除</el-button>
</div>
</div>
</div>
<div v-if="filteredScenes.length > pageSize" style="display:flex;justify-content:center;margin-top:8px">
<el-pagination v-model:current-page="scenePage" :page-size="pageSize" :total="filteredScenes.length" layout="prev, pager, next" small />
</div>
</el-tab-pane>
<el-tab-pane label="道具" name="prop">
<div class="sub-toolbar">
<el-input v-model="propSearch" placeholder="搜索道具..." clearable size="small" style="width:200px" />
<el-button size="small" type="primary" @click="openPropForm()">+ 添加道具</el-button>
</div>
<div v-if="propsList.length === 0" style="color:#909399;font-size:13px;padding:8px 0;text-align:center">
暂无道具点击上方按钮添加
</div>
<div v-else-if="filteredProps.length === 0" style="color:#909399;font-size:13px;padding:8px 0;text-align:center">
未找到匹配的道具
</div>
<div v-else class="sub-list">
<div v-for="(p, i) in paginatedProps" :key="p.id" class="sub-item">
<el-avatar v-if="p.imagePath" :src="p.imagePath" shape="square" :size="36" />
<el-avatar v-else shape="square" :size="36">{{ p.name ? p.name.charAt(0) : '?' }}</el-avatar>
<div class="sub-info">
<strong>{{ p.name }}</strong>
<div v-if="p.description" style="color:#909399;font-size:11px">{{ p.description }}</div>
</div>
<div class="sub-actions">
<el-button size="small" text @click="openPropForm(p, i)">编辑</el-button>
<el-button size="small" text type="danger" @click="handleDeleteProp(p, i)">删除</el-button>
</div>
</div>
</div>
<div v-if="filteredProps.length > pageSize" style="display:flex;justify-content:center;margin-top:8px">
<el-pagination v-model:current-page="propPage" :page-size="pageSize" :total="filteredProps.length" layout="prev, pager, next" small />
</div>
</el-tab-pane>
<el-tab-pane label="背景音" name="backgroundMusic">
<div class="sub-toolbar">
<el-input v-model="bgmSearch" placeholder="搜索背景音..." clearable size="small" style="width:200px" />
<el-button size="small" type="primary" @click="openBgmForm()">+ 添加背景音</el-button>
</div>
<div v-if="bgmList.length === 0" style="color:#909399;font-size:13px;padding:8px 0;text-align:center">
暂无背景音点击上方按钮添加
</div>
<div v-else-if="filteredBgm.length === 0" style="color:#909399;font-size:13px;padding:8px 0;text-align:center">
未找到匹配的背景音
</div>
<div v-else class="sub-list">
<div v-for="(m, i) in paginatedBgm" :key="m.id" class="sub-item">
<el-avatar shape="square" :size="36" style="background:#b37feb">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55C7.79 13 6 14.79 6 17s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
</el-avatar>
<div class="sub-info">
<strong>{{ m.name }}</strong>
<span v-if="m.filePath" style="color:#909399;font-size:12px"> · {{ m.filePath.split('/').pop() }}</span>
</div>
<div class="sub-actions">
<el-button size="small" text @click="openBgmForm(m, i)">编辑</el-button>
<el-button size="small" text type="danger" @click="handleDeleteBgm(m, i)">删除</el-button>
</div>
</div>
</div>
<div v-if="filteredBgm.length > pageSize" style="display:flex;justify-content:center;margin-top:8px">
<el-pagination v-model:current-page="bgmPage" :page-size="pageSize" :total="filteredBgm.length" layout="prev, pager, next" small />
</div>
</el-tab-pane>
</el-tabs>
<!-- Character form dialog -->
<el-dialog :title="charEditingIdx >= 0 ? '编辑演员' : '添加演员'" v-model="charFormVisible" width="420px" class="inner-dialog-mobile" append-to-body destroy-on-close>
<el-form :model="charForm" label-position="top" size="small">
<el-form-item label="演员名" required>
<el-input v-model="charForm.name" placeholder="请输入演员名" />
</el-form-item>
<el-form-item label="演员描述">
<el-input v-model="charForm.description" placeholder="请输入演员描述" />
</el-form-item>
<el-form-item label="声音文件">
<div class="upload-row">
<div class="upload-box" @click="charVoiceInput?.click()">
<input ref="charVoiceInput" type="file" accept="audio/*" hidden @change="onCharVoiceChange" />
<span class="upload-plus">+</span>
</div>
<div v-if="charVoicePreview" class="upload-player-wrap" @click.stop>
<audio :src="charVoicePreview" controls style="height:32px;width:100%" />
</div>
<span v-else class="upload-hint">点击加号选择音频文件</span>
</div>
</el-form-item>
<el-form-item label="人物形象">
<div class="upload-box" @click="charPortraitInput?.click()">
<input ref="charPortraitInput" type="file" accept="image/*" hidden @change="onCharPortraitChange" />
<img v-if="charPortraitPreview" :src="charPortraitPreview" class="upload-preview" />
<div v-else class="upload-placeholder"><span class="upload-plus">+</span></div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button type="primary" :loading="charSaving" @click="handleSaveChar">保存</el-button>
</template>
</el-dialog>
<!-- Scene form dialog -->
<el-dialog :title="sceneEditingIdx >= 0 ? '编辑场景' : '添加场景'" v-model="sceneFormVisible" width="420px" class="inner-dialog-mobile" append-to-body destroy-on-close>
<el-form :model="sceneForm" label-position="top" size="small">
<el-form-item label="场景名" required>
<el-input v-model="sceneForm.name" placeholder="请输入场景名" />
</el-form-item>
<el-form-item label="场景描述">
<el-input v-model="sceneForm.description" placeholder="请输入场景描述" />
</el-form-item>
<el-form-item label="场景图片">
<div class="upload-box" @click="sceneImageInput?.click()">
<input ref="sceneImageInput" type="file" accept="image/*" hidden @change="onSceneImageChange" />
<img v-if="sceneImagePreview" :src="sceneImagePreview" class="upload-preview" />
<div v-else class="upload-placeholder"><span class="upload-plus">+</span></div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button type="primary" :loading="sceneSaving" @click="handleSaveScene">保存</el-button>
</template>
</el-dialog>
<!-- Prop form dialog -->
<el-dialog :title="propEditingIdx >= 0 ? '编辑道具' : '添加道具'" v-model="propFormVisible" width="420px" class="inner-dialog-mobile" append-to-body destroy-on-close>
<el-form :model="propForm" label-position="top" size="small">
<el-form-item label="道具名" required>
<el-input v-model="propForm.name" placeholder="请输入道具名" />
</el-form-item>
<el-form-item label="道具描述">
<el-input v-model="propForm.description" placeholder="请输入道具描述" />
</el-form-item>
<el-form-item label="道具图片">
<div class="upload-box" @click="propImageInput?.click()">
<input ref="propImageInput" type="file" accept="image/*" hidden @change="onPropImageChange" />
<img v-if="propImagePreview" :src="propImagePreview" class="upload-preview" />
<div v-else class="upload-placeholder"><span class="upload-plus">+</span></div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button type="primary" :loading="propSaving" @click="handleSaveProp">保存</el-button>
</template>
</el-dialog>
<!-- Background music form dialog -->
<el-dialog :title="bgmEditingIdx >= 0 ? '编辑背景音' : '添加背景音'" v-model="bgmFormVisible" width="420px" class="inner-dialog-mobile" append-to-body destroy-on-close>
<el-form :model="bgmForm" label-position="top" size="small">
<el-form-item label="背景音名称" required>
<el-input v-model="bgmForm.name" placeholder="请输入背景音名称" />
</el-form-item>
<el-form-item label="音频文件">
<input type="file" accept="audio/*" @change="e => bgmAudioFile = e.target.files[0]" />
<div v-if="bgmForm.filePath && !bgmAudioFile" style="font-size:12px;color:#909399;margin-top:4px">当前文件: {{ bgmForm.filePath.split('/').pop() }}</div>
</el-form-item>
</el-form>
<template #footer>
<el-button type="primary" :loading="bgmSaving" @click="handleSaveBgm">保存</el-button>
</template>
</el-dialog>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { createDrama, updateDrama, getDrama, getFieldDefinitions } from '@/api/drama'
import { addCharacter, updateCharacter, deleteCharacter } from '@/api/character'
import { addScene, updateScene, deleteScene } from '@/api/scene'
import { addProp, updateProp, deleteProp } from '@/api/prop'
import { addBackgroundMusic, updateBackgroundMusic, deleteBackgroundMusic } from '@/api/backgroundMusic'
const props = defineProps({
modelValue: Boolean,
dramaId: { type: [Number, String], default: null },
initialTab: { type: String, default: 'basic' }
})
const emit = defineEmits(['update:modelValue', 'saved'])
const visible = ref(false)
const activeTab = ref('basic')
const saving = ref(false)
const isEdit = computed(() => !!props.dramaId)
// 内容类型字段定义(从API获取)
const contentTypes = ref([])
const fieldDefsMap = ref({})
const form = ref({
title: '',
type: '',
config: {},
aspectRatio: '',
episodeDuration: 60,
resolution: ''
})
const currentFields = computed(() => {
return fieldDefsMap.value[form.value.type] || []
})
// 是否显示子实体tab(演员/场景/道具/背景音 — 仅短剧/漫剧需要)
// 切换类型时重置config
function onTypeChange() {
form.value.config = {}
}
// Sub-entity lists
const characters = ref([])
const scenes = ref([])
const propsList = ref([])
// Client-side pagination for sub-lists
const pageSize = 20
const charPage = ref(1)
const scenePage = ref(1)
const propPage = ref(1)
const charSearch = ref('')
const sceneSearch = ref('')
const propSearch = ref('')
const filteredChars = computed(() => {
const q = charSearch.value.trim().toLowerCase()
if (!q) return characters.value
return characters.value.filter(c => (c.name || '').toLowerCase().includes(q) || (c.description || '').toLowerCase().includes(q))
})
const filteredScenes = computed(() => {
const q = sceneSearch.value.trim().toLowerCase()
if (!q) return scenes.value
return scenes.value.filter(s => (s.name || '').toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q))
})
const filteredProps = computed(() => {
const q = propSearch.value.trim().toLowerCase()
if (!q) return propsList.value
return propsList.value.filter(p => (p.name || '').toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q))
})
const paginatedChars = computed(() => {
const list = filteredChars.value
if (list.length <= pageSize) return list
const start = (charPage.value - 1) * pageSize
return list.slice(start, start + pageSize)
})
const paginatedScenes = computed(() => {
const list = filteredScenes.value
if (list.length <= pageSize) return list
const start = (scenePage.value - 1) * pageSize
return list.slice(start, start + pageSize)
})
const paginatedProps = computed(() => {
const list = filteredProps.value
if (list.length <= pageSize) return list
const start = (propPage.value - 1) * pageSize
return list.slice(start, start + pageSize)
})
const filteredBgm = computed(() => {
const q = bgmSearch.value.trim().toLowerCase()
if (!q) return bgmList.value
return bgmList.value.filter(m => (m.name || '').toLowerCase().includes(q))
})
const paginatedBgm = computed(() => {
const list = filteredBgm.value
if (list.length <= pageSize) return list
const start = (bgmPage.value - 1) * pageSize
return list.slice(start, start + pageSize)
})
// Character form
const charFormVisible = ref(false)
const charSaving = ref(false)
const charEditingIdx = ref(-1)
const charForm = ref({ name: '', description: '' })
const charVoiceFile = ref(null)
const charPortraitFile = ref(null)
const charVoicePreview = ref('')
const charPortraitPreview = ref('')
const charVoiceInput = ref(null)
const charPortraitInput = ref(null)
function onCharPortraitChange(e) {
const file = e.target.files[0]
charPortraitFile.value = file
e.target.value = ''
if (file) {
if (charPortraitPreview.value && charPortraitPreview.value.startsWith('blob:')) URL.revokeObjectURL(charPortraitPreview.value)
charPortraitPreview.value = URL.createObjectURL(file)
}
}
function onCharVoiceChange(e) {
const file = e.target.files[0]
charVoiceFile.value = file
e.target.value = ''
if (file) {
if (charVoicePreview.value && charVoicePreview.value.startsWith('blob:')) URL.revokeObjectURL(charVoicePreview.value)
charVoicePreview.value = URL.createObjectURL(file)
}
}
// Scene form
const sceneFormVisible = ref(false)
const sceneSaving = ref(false)
const sceneEditingIdx = ref(-1)
const sceneForm = ref({ name: '', description: '' })
const sceneImageFile = ref(null)
const sceneImagePreview = ref('')
const sceneImageInput = ref(null)
function onSceneImageChange(e) {
const file = e.target.files[0]
sceneImageFile.value = file
e.target.value = ''
if (file) {
if (sceneImagePreview.value && sceneImagePreview.value.startsWith('blob:')) URL.revokeObjectURL(sceneImagePreview.value)
sceneImagePreview.value = URL.createObjectURL(file)
}
}
// Prop form
const propFormVisible = ref(false)
const propSaving = ref(false)
const propEditingIdx = ref(-1)
const propForm = ref({ name: '', description: '' })
const propImageFile = ref(null)
const propImagePreview = ref('')
const propImageInput = ref(null)
function onPropImageChange(e) {
const file = e.target.files[0]
propImageFile.value = file
e.target.value = ''
if (file) {
if (propImagePreview.value && propImagePreview.value.startsWith('blob:')) URL.revokeObjectURL(propImagePreview.value)
propImagePreview.value = URL.createObjectURL(file)
}
}
// Background music
const bgmList = ref([])
const bgmPage = ref(1)
const bgmSearch = ref('')
const bgmFormVisible = ref(false)
const bgmSaving = ref(false)
const bgmEditingIdx = ref(-1)
const bgmForm = ref({ name: '', filePath: '' })
const bgmAudioFile = ref(null)
// 加载字段定义
async function loadFieldDefs() {
try {
const data = await getFieldDefinitions()
contentTypes.value = data.contentTypes || []
fieldDefsMap.value = data.fields || {}
} catch (e) {
// fallback: 硬编码默认值
contentTypes.value = ['短剧', '漫剧', '广告视频']
}
}
watch(() => props.modelValue, async (val) => {
visible.value = val
if (val) {
// 先重置表单,避免编辑残留值污染新建弹窗
form.value = { title: '', type: '', config: {}, aspectRatio: '', episodeDuration: 60, resolution: '' }
characters.value = []; scenes.value = []; propsList.value = []; bgmList.value = []
charPage.value = 1; scenePage.value = 1; propPage.value = 1; bgmPage.value = 1
charSearch.value = ''; sceneSearch.value = ''; propSearch.value = ''; bgmSearch.value = ''
activeTab.value = props.initialTab || 'basic'
await loadFieldDefs()
if (props.dramaId) {
await loadDramaDetail()
} else {
const defaultType = contentTypes.value[0] || ''
const fields = fieldDefsMap.value[defaultType] || []
const configDefaults = {}
fields.forEach(fd => {
if (fd.options && fd.options.length > 0) {
configDefaults[fd.key] = fd.multi ? [fd.options[0]] : fd.options[0]
}
})
form.value = {
title: '',
type: defaultType,
config: configDefaults,
aspectRatio: '9:16竖屏',
episodeDuration: 60,
resolution: '720P'
}
}
}
})
function handleClose() {
emit('update:modelValue', false)
}
async function loadDramaDetail() {
if (!props.dramaId) return
try {
const data = await getDrama(props.dramaId)
form.value.title = data.title || ''
form.value.type = data.type || ''
form.value.episodeDuration = data.episodeDuration || 60
form.value.aspectRatio = data.aspectRatio || ''
form.value.resolution = data.resolution || ''
// 解析config JSON
try {
form.value.config = JSON.parse(data.config || '{}')
} catch (e) {
form.value.config = {}
}
// 向后兼容:多选字段旧数据是字符串格式,转为数组
const fields = fieldDefsMap.value[data.type] || []
fields.forEach(fd => {
if (fd.multi && typeof form.value.config[fd.key] === 'string') {
form.value.config[fd.key] = [form.value.config[fd.key]]
}
})
characters.value = data.characters || []
scenes.value = data.scenes || []
propsList.value = data.props || []
bgmList.value = data.backgroundMusic || []
charPage.value = 1; scenePage.value = 1; propPage.value = 1; bgmPage.value = 1
charSearch.value = ''; sceneSearch.value = ''; propSearch.value = ''; bgmSearch.value = ''
} catch (e) {}
}
async function handleSave() {
if (!form.value.title) { ElMessage.warning('请输入标题'); return }
if (!form.value.type) { ElMessage.warning('请选择内容类型'); return }
// 验证类型专属字段
for (const fd of currentFields.value) {
if (fd.required && (form.value.config[fd.key] == null || (Array.isArray(form.value.config[fd.key]) && form.value.config[fd.key].length === 0))) {
ElMessage.warning(`请选择${fd.label}`)
return
}
}
// 时长字段校验(所有类型都需要)
if (!form.value.episodeDuration) {
ElMessage.warning('请设置时长')
return
}
saving.value = true
try {
const payload = {
title: form.value.title,
type: form.value.type,
config: JSON.stringify(form.value.config),
}
payload.episodeDuration = form.value.episodeDuration
if (form.value.aspectRatio) {
payload.aspectRatio = form.value.aspectRatio
}
if (form.value.resolution) {
payload.resolution = form.value.resolution
}
if (isEdit.value) {
payload.id = props.dramaId
await updateDrama(payload)
ElMessage.success('已保存')
visible.value = false
} else {
const res = await createDrama(payload)
ElMessage.success('项目已创建')
emit('saved', res.id)
visible.value = false
}
} catch (e) {} finally {
saving.value = false
}
}
// ============ Character CRUD ============
function openCharForm(char) {
if (!props.dramaId) { ElMessage.warning('请先保存基础信息'); return }
if (char) {
charEditingIdx.value = characters.value.findIndex(c => c.id === char.id)
charForm.value = { name: char.name, description: char.description }
} else {
charEditingIdx.value = -1
charForm.value = { name: '', description: '' }
}
charVoiceFile.value = null
charPortraitFile.value = null
;[charPortraitPreview, charVoicePreview].forEach(r => {
if (r.value && r.value.startsWith('blob:')) URL.revokeObjectURL(r.value)
r.value = ''
})
if (char) {
charPortraitPreview.value = char.portraitPath || ''
charVoicePreview.value = char.voicePath || ''
}
charFormVisible.value = true
}
async function handleSaveChar() {
if (!charForm.value.name) { ElMessage.warning('请输入演员名'); return }
charSaving.value = true
try {
const fd = new FormData()
fd.append('dramaId', String(props.dramaId))
fd.append('name', charForm.value.name)
fd.append('description', charForm.value.description)
if (charVoiceFile.value) fd.append('voiceFile', charVoiceFile.value)
if (charPortraitFile.value) fd.append('portraitFile', charPortraitFile.value)
if (charEditingIdx.value >= 0) {
const c = characters.value[charEditingIdx.value]
fd.append('charId', String(c.id))
await updateCharacter(fd)
} else {
await addCharacter(fd)
}
charFormVisible.value = false
ElMessage.success('保存成功')
await loadDramaDetail()
} catch (e) {} finally {
charSaving.value = false
}
}
async function handleDeleteChar(c, idx) {
try {
await ElMessageBox.confirm(`确定要删除演员「${c.name}」吗?`, '确认', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
})
await deleteCharacter({ dramaId: props.dramaId, charId: c.id })
ElMessage.success('已删除')
await loadDramaDetail()
} catch (e) { if (e !== 'cancel') throw e }
}
// ============ Scene CRUD ============
function openSceneForm(scene) {
if (!props.dramaId) { ElMessage.warning('请先保存基础信息'); return }
if (scene) {
sceneEditingIdx.value = scenes.value.findIndex(s => s.id === scene.id)
sceneForm.value = { name: scene.name, description: scene.description }
} else {
sceneEditingIdx.value = -1
sceneForm.value = { name: '', description: '' }
}
sceneImageFile.value = null
if (sceneImagePreview.value && sceneImagePreview.value.startsWith('blob:')) URL.revokeObjectURL(sceneImagePreview.value)
sceneImagePreview.value = scene?.imagePath || ''
sceneFormVisible.value = true
}
async function handleSaveScene() {
if (!sceneForm.value.name) { ElMessage.warning('请输入场景名'); return }
sceneSaving.value = true
try {
const fd = new FormData()
fd.append('dramaId', String(props.dramaId))
fd.append('name', sceneForm.value.name)
fd.append('description', sceneForm.value.description)
if (sceneImageFile.value) fd.append('imageFile', sceneImageFile.value)
if (sceneEditingIdx.value >= 0) {
const s = scenes.value[sceneEditingIdx.value]
fd.append('id', String(s.id))
await updateScene(fd)
} else {
await addScene(fd)
}
sceneFormVisible.value = false
ElMessage.success('保存成功')
await loadDramaDetail()
} catch (e) {} finally {
sceneSaving.value = false
}
}
async function handleDeleteScene(s, idx) {
try {
await ElMessageBox.confirm(`确定要删除场景「${s.name}」吗?`, '确认', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
})
await deleteScene({ id: s.id, dramaId: props.dramaId })
ElMessage.success('已删除')
await loadDramaDetail()
} catch (e) { if (e !== 'cancel') throw e }
}
// ============ Prop CRUD ============
function openPropForm(prop) {
if (!props.dramaId) { ElMessage.warning('请先保存基础信息'); return }
if (prop) {
propEditingIdx.value = propsList.value.findIndex(p => p.id === prop.id)
propForm.value = { name: prop.name, description: prop.description }
} else {
propEditingIdx.value = -1
propForm.value = { name: '', description: '' }
}
propImageFile.value = null
if (propImagePreview.value && propImagePreview.value.startsWith('blob:')) URL.revokeObjectURL(propImagePreview.value)
propImagePreview.value = prop?.imagePath || ''
propFormVisible.value = true
}
async function handleSaveProp() {
if (!propForm.value.name) { ElMessage.warning('请输入道具名'); return }
propSaving.value = true
try {
const fd = new FormData()
fd.append('dramaId', String(props.dramaId))
fd.append('name', propForm.value.name)
fd.append('description', propForm.value.description)
if (propImageFile.value) fd.append('imageFile', propImageFile.value)
if (propEditingIdx.value >= 0) {
const p = propsList.value[propEditingIdx.value]
fd.append('id', String(p.id))
await updateProp(fd)
} else {
await addProp(fd)
}
propFormVisible.value = false
ElMessage.success('保存成功')
await loadDramaDetail()
} catch (e) {} finally {
propSaving.value = false
}
}
async function handleDeleteProp(p, idx) {
try {
await ElMessageBox.confirm(`确定要删除道具「${p.name}」吗?`, '确认', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
})
await deleteProp({ id: p.id, dramaId: props.dramaId })
ElMessage.success('已删除')
await loadDramaDetail()
} catch (e) { if (e !== 'cancel') throw e }
}
// ============ Background Music CRUD ============
function openBgmForm(bgm) {
if (!props.dramaId) { ElMessage.warning('请先保存基础信息'); return }
if (bgm) {
bgmEditingIdx.value = bgmList.value.findIndex(m => m.id === bgm.id)
bgmForm.value = { name: bgm.name, filePath: bgm.filePath || '' }
} else {
bgmEditingIdx.value = -1
bgmForm.value = { name: '', filePath: '' }
}
bgmAudioFile.value = null
bgmFormVisible.value = true
}
async function handleSaveBgm() {
if (!bgmForm.value.name) { ElMessage.warning('请输入背景音名称'); return }
bgmSaving.value = true
try {
const fd = new FormData()
fd.append('dramaId', String(props.dramaId))
fd.append('name', bgmForm.value.name)
if (bgmAudioFile.value) fd.append('audioFile', bgmAudioFile.value)
if (bgmEditingIdx.value >= 0) {
const m = bgmList.value[bgmEditingIdx.value]
fd.append('id', String(m.id))
await updateBackgroundMusic(fd)
} else {
await addBackgroundMusic(fd)
}
bgmFormVisible.value = false
ElMessage.success('保存成功')
await loadDramaDetail()
} catch (e) {} finally {
bgmSaving.value = false
}
}
async function handleDeleteBgm(m, idx) {
try {
await ElMessageBox.confirm(`确定要删除背景音「${m.name}」吗?`, '确认', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
})
await deleteBackgroundMusic({ id: m.id, dramaId: props.dramaId })
ElMessage.success('已删除')
await loadDramaDetail()
} catch (e) { if (e !== 'cancel') throw e }
}
</script>
<style scoped>
.basic-tab-scroll { flex:1; min-height:0; overflow-y:auto; }
.drama-edit-dialog :deep(.el-tabs--border-card) { display:flex; flex:1; flex-direction:column; min-height:0; border:none; }
.drama-edit-dialog :deep(.el-tabs--border-card > .el-tabs__content) { flex:1; min-height:0; overflow:hidden; display:flex; flex-direction:column; }
.drama-edit-dialog :deep(.el-tabs--border-card > .el-tabs__content .el-tab-pane) { flex:1; min-height:0; overflow:auto; display:flex; flex-direction:column; }
.sub-toolbar { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
.sub-list { display:flex; flex-direction:column; gap:6px; }
.sub-item { display:flex; align-items:center; gap:8px; padding:8px 10px; background:#fafafa; border-radius:8px; border:1px solid #eee; }
.sub-info { flex:1; font-size:13px; min-width:0; }
.sub-actions { display:flex; gap:4px; flex-shrink:0; }
.audio-input, .image-input { max-width:100%; }
.upload-box {
width: 72px;
height: 72px;
border: 2px dashed #d9d9d9;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
overflow: hidden;
position: relative;
background: #fafafa;
transition: border-color .2s, background .2s;
}
.upload-box:hover { border-color: #000; background: #f0f0f0; }
.upload-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.upload-plus {
font-size: 28px;
color: #bbb;
line-height: 1;
font-weight: 300;
transition: color .2s;
}
.upload-box:hover .upload-plus { color: #000; }
.upload-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.upload-player-wrap {
flex: 1;
min-width: 0;
}
.upload-hint {
font-size: 12px;
color: #bbb;
}
@media (max-width:640px) {
.sub-item { flex-wrap:wrap; gap:6px; }
.sub-info { width:calc(100% - 80px); }
.sub-actions { width:100%; justify-content:flex-end; padding-top:4px; }
}
</style>
@@ -0,0 +1,403 @@
<template>
<el-dialog v-model="visible" width="80%" top="5vh" class="dialog-60h" @closed="handleClose" destroy-on-close>
<template #header>
<div style="font-size:16px;font-weight:600">{{ dramaTitle }} - 剧集管理</div>
</template>
<div class="ep-toolbar">
<el-input v-model="epSearch" placeholder="搜索剧集..." clearable size="small" style="width:200px" @keyup.enter="handleSearch" @clear="handleSearch" />
<div>
<el-button size="small" @click="handleSearch">搜索</el-button>
<el-button size="small" type="primary" @click="openEpForm()">+ 添加剧集</el-button>
</div>
</div>
<div v-loading="loading" class="ep-list">
<div v-if="episodes.length === 0 && !loading" style="text-align:center;color:#909399;padding:20px 0">
{{ searchKeyword ? '未找到匹配的剧集' : '暂无剧集点击上方"添加剧集"按钮创建' }}
</div>
<div v-for="ep in episodes" :key="ep.id" class="ep-item">
<div class="ep-index">{{ ep.index || '-' }}</div>
<div class="ep-info">
<div class="ep-title">{{ ep.title || '未命名' }}</div>
<div v-if="ep.description" class="ep-desc">{{ ep.description.length > 80 ? ep.description.substring(0, 80) + '...' : ep.description }}</div>
<div v-if="ep.status" class="ep-status" :class="ep.status">
<template v-if="ep.status === 'pending'">待处理</template>
<template v-else-if="ep.status === 'generating'">生成中</template>
<template v-else-if="ep.status === 'completed'">已完成</template>
<template v-else-if="ep.status === 'failed'">失败</template>
<template v-else>{{ ep.status }}</template>
</div>
</div>
<div class="ep-actions">
<el-button v-if="!ep.status || ep.status === 'pending' || ep.status === 'failed'" size="small" type="success" :loading="generatingEpId === ep.id" @click="handleGenerate(ep)">生成视频</el-button>
<el-button v-if="ep.status === 'generating' || ep.status === 'review'" size="small" type="primary" @click="handleReview(ep)">审核</el-button>
<el-button v-if="ep.status === 'completed'" size="small" type="success" @click="handlePreview(ep)">预览</el-button>
<el-button size="small" @click="openEpForm(ep)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDeleteEp(ep)">删除</el-button>
</div>
</div>
</div>
<template #footer>
<div v-if="total > 0" style="display:flex;justify-content:center;flex:1">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
small
@current-change="loadEpisodes"
@size-change="onSizeChange"
/>
</div>
</template>
<!-- Episode form dialog -->
<el-dialog :title="epEditingId ? '编辑剧集' : '添加剧集'" v-model="epFormVisible" width="600px" class="inner-dialog-mobile" append-to-body destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false" :before-close="onBeforeFormClose">
<el-form :model="epForm" label-position="top" size="small">
<el-form-item label="剧集标题" required>
<el-input v-model="epForm.title" placeholder="请输入剧集标题" />
</el-form-item>
<el-form-item label="剧情描述" required>
<el-input v-model="epForm.description" type="textarea" :rows="3" placeholder="请输入剧情描述(剧情概述、背景设定等),脚本将根据剧情描述生成" />
</el-form-item>
<el-form-item label="剧集脚本" required>
<div style="margin-bottom:8px">
<el-button size="small" type="primary" :loading="scriptGenerating" :disabled="!epForm.description" @click="handleGenerateScript">
{{ scriptGenerating ? '生成中...' : '生成脚本' }}
</el-button>
</div>
<ShotTimelineEditor
v-model="shots"
:characters="dramaCharacters"
:scenes="dramaScenes"
:props="dramaProps"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button type="primary" :loading="epSaving" :disabled="!hasScript" @click="handleSaveEp">保存</el-button>
</template>
</el-dialog>
<!-- Review dialog -->
<ReviewDialog v-model="reviewVisible" :episode="reviewEpisode" :tasks="reviewTasks" :current-task-id="reviewCurrentTaskId" @refresh="onReviewRefresh" />
</el-dialog>
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listEpisodes, addEpisode, updateEpisode, deleteEpisode, generateEpisode, pollEpisode, getEpisodeTasks, generateEpisodeScript } from '@/api/episode'
import { listCharacters } from '@/api/character'
import { listScenes } from '@/api/scene'
import { listProps } from '@/api/prop'
import ReviewDialog from './ReviewDialog.vue'
import ShotTimelineEditor from './ShotTimelineEditor.vue'
const props = defineProps({
modelValue: Boolean,
dramaId: { type: [Number, String], default: null },
dramaTitle: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue', 'refresh', 'editActors'])
const visible = ref(false)
const loading = ref(false)
const episodes = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const epSearch = ref('')
const searchKeyword = ref('')
// Episode form
const epFormVisible = ref(false)
const epSaving = ref(false)
const epEditingId = ref(null)
const epForm = ref({ title: '', description: '', script: '' })
const scriptGenerating = ref(false)
// Drama entity lists for shot editor
const dramaCharacters = ref([])
const dramaScenes = ref([])
const dramaProps = ref([])
// Shots timeline editor
const shots = ref([])
const hasScript = computed(() => epForm.value.script !== '' || shots.value.length > 0)
// Generation
const generatingEpId = ref(null)
const pollTimer = ref(null)
// Review
const reviewVisible = ref(false)
const reviewEpisode = ref(null)
const reviewTasks = ref([])
const reviewCurrentTaskId = ref(null)
// Polling state tracking
const lastPollStatus = ref({})
watch(() => props.modelValue, async (val) => {
visible.value = val
if (val && props.dramaId) {
await loadEpisodes()
}
})
function onBeforeFormClose(done) {
if (epSaving.value) return
done()
}
function handleClose() {
stopPolling()
emit('update:modelValue', false)
}
onUnmounted(() => {
stopPolling()
})
async function loadEpisodes() {
loading.value = true
try {
const data = await listEpisodes(props.dramaId, page.value, pageSize.value, searchKeyword.value)
episodes.value = data.list || []
total.value = data.total || 0
} catch (e) {
} finally {
loading.value = false
}
}
function handleSearch() {
searchKeyword.value = epSearch.value.trim()
page.value = 1
loadEpisodes()
}
function onSizeChange(size) {
pageSize.value = size
page.value = 1
loadEpisodes()
}
// ============ Episode CRUD ============
async function loadDramaEntities() {
try {
const [chars, scenes, propsData] = await Promise.all([
listCharacters(props.dramaId),
listScenes(props.dramaId),
listProps(props.dramaId)
])
dramaCharacters.value = chars.list || []
dramaScenes.value = scenes.list || []
dramaProps.value = propsData.list || []
} catch (e) {}
}
async function openEpForm(ep) {
await loadDramaEntities()
if (ep) {
epEditingId.value = ep.id
epForm.value = { title: ep.title, description: ep.description || '', script: ep.script }
parseScript(ep.script)
} else {
epEditingId.value = null
epForm.value = { title: '', description: '', script: '' }
shots.value = []
}
epFormVisible.value = true
}
function parseScript(scriptStr) {
if (!scriptStr) {
shots.value = []
return
}
try {
const parsed = JSON.parse(scriptStr)
if (Array.isArray(parsed) && parsed.length > 0) {
shots.value = parsed
return
}
} catch (e) {
// Not JSON
}
shots.value = []
}
async function handleGenerateScript() {
if (!epForm.value.description) { ElMessage.warning('请先输入剧情描述'); return }
scriptGenerating.value = true
try {
const data = await generateEpisodeScript({ dramaId: Number(props.dramaId), title: epForm.value.title, description: epForm.value.description })
epForm.value.script = data.script
// Parse JSON shots
try {
const parsed = JSON.parse(data.script)
if (Array.isArray(parsed) && parsed.length > 0) {
shots.value = parsed
}
} catch (e) {
// ignore
}
ElMessage.success('脚本生成成功')
} catch (e) {
ElMessage.error('脚本生成失败')
} finally {
scriptGenerating.value = false
}
}
async function handleSaveEp() {
if (epSaving.value) return
if (!epForm.value.title) { ElMessage.warning('请输入剧集标题'); return }
if (!epForm.value.description) { ElMessage.warning('请输入剧情描述'); return }
// Serialize shots to JSON
if (shots.value.length > 0) {
epForm.value.script = JSON.stringify(shots.value)
}
if (!epForm.value.script) { ElMessage.warning('请先生成或输入剧集脚本'); return }
epSaving.value = true
try {
const body = {
dramaId: Number(props.dramaId),
title: epForm.value.title,
description: epForm.value.description,
script: epForm.value.script
}
if (epEditingId.value) {
body.epId = Number(epEditingId.value)
await updateEpisode(body)
} else {
await addEpisode(body)
}
epFormVisible.value = false
ElMessage.success('保存成功')
await loadEpisodes()
} catch (e) {
ElMessage.error(typeof e === 'string' ? e : (e.message || e.msg || '保存失败'))
} finally {
epSaving.value = false
}
}
async function handleDeleteEp(ep) {
try {
await ElMessageBox.confirm(`确定要删除剧集「${ep.title}」吗?`, '确认', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
})
await deleteEpisode({ dramaId: Number(props.dramaId), epId: ep.id })
ElMessage.success('已删除')
await loadEpisodes()
} catch (e) { if (e !== 'cancel') throw e }
}
// ============ Generation & Polling ============
async function handleGenerate(ep) {
generatingEpId.value = ep.id
try {
await generateEpisode({ dramaId: Number(props.dramaId), epId: ep.id })
ElMessage.success('开始生成')
startPolling(ep.id)
} catch (e) {
generatingEpId.value = null
}
}
function startPolling(epId) {
stopPolling()
lastPollStatus.value = {}
pollOnce(epId)
pollTimer.value = setInterval(() => pollOnce(epId), 15000)
}
function stopPolling() {
if (pollTimer.value) {
clearInterval(pollTimer.value)
pollTimer.value = null
}
generatingEpId.value = null
}
async function pollOnce(epId) {
try {
const data = await pollEpisode(epId)
const status = data.status
const prevStatus = lastPollStatus.value[epId]
if (status === 'generating' && prevStatus !== 'generating') {
await loadEpisodes()
} else if (status === 'review' && prevStatus !== 'review') {
await loadEpisodes()
// Auto-open review panel
const tasks = await getEpisodeTasks(epId)
reviewEpisode.value = episodes.value.find(e => e.id === epId) || null
reviewTasks.value = tasks.tasks || []
reviewCurrentTaskId.value = tasks.currentTaskId
reviewVisible.value = true
} else if (status === 'completed' && prevStatus !== 'completed') {
stopPolling()
await loadEpisodes()
} else if (status === 'failed' && prevStatus !== 'failed') {
await loadEpisodes()
}
lastPollStatus.value[epId] = status
} catch (e) {
// Ignore poll errors
}
}
// ============ Review & Preview ============
async function handleReview(ep) {
try {
const data = await getEpisodeTasks(ep.id)
reviewEpisode.value = ep
reviewTasks.value = data.tasks || []
reviewCurrentTaskId.value = data.currentTaskId
reviewVisible.value = true
} catch (e) {}
}
async function handlePreview(ep) {
reviewEpisode.value = ep
reviewTasks.value = []
reviewCurrentTaskId.value = null
reviewVisible.value = true
}
function onReviewRefresh() {
loadEpisodes()
}
</script>
<style scoped>
.ep-toolbar { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
.ep-list { display:flex; flex-direction:column; gap:8px; min-height:100px; }
.ep-item { display:flex; gap:12px; align-items:flex-start; padding:12px 16px; background:#fafafa; border-radius:8px; border:1px solid #eee; }
.ep-index { width:28px; height:28px; border-radius:50%; background:#409eff; color:#fff; display:flex; align-items:center; justify-content:center; font-size:13px; font-weight:600; flex-shrink:0; }
.ep-info { flex:1; min-width:0; }
.ep-title { font-size:14px; font-weight:600; }
.ep-desc { font-size:12px; color:#606266; margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ep-script { font-size:12px; color:#909399; margin-top:4px; cursor:pointer; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
.ep-script.expanded { -webkit-line-clamp:unset; }
.ep-status { font-size:11px; margin-top:4px; }
.ep-status.pending { color:#909399; }
.ep-status.generating { color:#e6a23c; }
.ep-status.completed { color:#67c23a; }
.ep-status.failed { color:#f56c6c; }
.ep-actions { display:flex; gap:6px; flex-shrink:0; flex-wrap:wrap; }
@media (max-width:640px) {
.ep-item { flex-direction:column; gap:8px; }
.ep-info { width:100%; }
.ep-actions { width:100%; justify-content:flex-end; }
.ep-actions .el-button { flex:1; }
}
</style>
+346
View File
@@ -0,0 +1,346 @@
<template>
<el-dialog v-model="visible" :title="dialogTitle" width="460px" :close-on-click-modal="false" @closed="handleClose" @open="handleOpen">
<!-- 步骤1表单 -->
<template v-if="step === 'form'">
<el-form label-width="100px">
<!-- admin-renew: 代理商信息 -->
<template v-if="mode === 'admin-renew' && agentInfo">
<el-form-item label="代理商">
<span>{{ agentInfo.name }}</span>
</el-form-item>
<el-form-item label="当前到期">
<span>{{ agentInfo.expiredAt || '-' }}</span>
</el-form-item>
<el-form-item label="当前省份">
<span>{{ agentInfo.province || '-' }}</span>
</el-form-item>
<el-form-item label="当前区域">
<span>{{ agentInfo.region || '-' }}</span>
</el-form-item>
</template>
<!-- agent-renew: 当前到期 -->
<template v-if="mode === 'agent-renew' && agentInfo?.expiredAt">
<el-form-item label="当前到期">
<span>{{ agentInfo.expiredAt }}</span>
</el-form-item>
</template>
<!-- 续费选择续费模式 -->
<template v-if="mode !== 'recharge'">
<el-form-item label="续费地区" required>
<el-cascader v-model="cascaderRegion" :options="cascadeOptions" style="width:100%" placeholder="请选择省/市" @change="onRegionChange" />
</el-form-item>
<template v-if="selectedRegion">
<el-form-item label="续费方案" required>
<el-select v-model="selectedPricingId" style="width:100%" placeholder="请选择续费方案">
<el-option v-for="plan in availablePlans" :key="plan.id" :value="plan.id">
<span style="display:flex;justify-content:space-between">
<span>{{ plan.protected ? '地区保护' : '普通' }}</span>
<span>{{ (plan.price / 100).toFixed(2) }}/</span>
<span>上限{{ plan.max_customers > 0 ? plan.max_customers + '人' : '不限' }}</span>
</span>
</el-option>
</el-select>
</el-form-item>
<template v-if="selectedPricing">
<el-form-item label="年费">
<span style="color:#409eff;font-size:16px;font-weight:600">{{ (selectedPricing.price / 100).toFixed(2) }} </span>
</el-form-item>
<el-form-item label="客户上限">
<span>{{ selectedPricing.max_customers > 0 ? selectedPricing.max_customers + ' 人' : '不限' }}</span>
</el-form-item>
</template>
</template>
<el-form-item label="续费年数" required>
<el-select v-model="duration" style="width:100%">
<el-option v-for="n in 5" :key="n" :label="n + '年'" :value="n" />
</el-select>
</el-form-item>
<el-form-item v-if="selectedPricing" label="合计">
<span style="color:#409eff;font-size:20px;font-weight:700">{{ ((selectedPricing.price * duration) / 100).toFixed(2) }} </span>
</el-form-item>
</template>
<!-- recharge: 充值金额 -->
<template v-if="mode === 'recharge'">
<el-form-item label="充值金额(元)">
<el-input-number v-model="amountInput" :min="1" :max="100000" style="width:100%" />
</el-form-item>
</template>
<!-- 支付方式在线支付模式 -->
<template v-if="mode !== 'admin-renew'">
<el-form-item label="支付方式">
<el-radio-group v-model="channel">
<el-radio value="alipay">
<span style="display:inline-flex;align-items:center;gap:4px">
<svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M860.4 694.4c-34-14.4-73.6-28-119.6-39.6 52-92.8 89.6-196.4 112-304H556v-96h264V216H556V96H444v120H180v39.2h264v96H276c-23.2 108 7.2 224 76 310.4 25.2-8.4 52.8-18 82-28.8 10-3.6 20-7.6 30-11.6-22-33.2-37.6-69.6-46-108H340v-96h280v112c0 0 0 0 0 0v23.6c0 24.4-3.6 48.4-10.4 71.2 31.2 8.8 56.8 16 78.4 22.4 88.8 28 144 68.4 165.6 118.8 21.6 50.4 3.6 108-54.8 154-58.4 46-156 72-276 72-120 0-217.6-26-276-72s-76.4-103.6-54.8-154c16.4-37.6 48-68 92.4-92.4-16.8-11.2-35.2-24.4-48.8-33.2-101.2 74.8-162.8 168.4-162.8 270 0 128 132 232 450 232s450-104 450-232c0-92.4-77.2-175.6-229.6-234z" fill="#1677ff"/></svg>
支付宝
</span>
</el-radio>
<el-radio value="wechat">
<span style="display:inline-flex;align-items:center;gap:4px">
<svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M368.5 367.9c-28.5 0-51.5 23-51.5 51.5s23 51.5 51.5 51.5 51.5-23 51.5-51.5-23-51.5-51.5-51.5z m277-0.1c-28.5 0-51.5 23-51.5 51.5s23 51.5 51.5 51.5 51.5-23 51.5-51.5-23-51.5-51.5-51.5z m100.7 299.5c135.4-53.7 217.8-153.7 217.8-268.6 0-175.7-181.6-318-405.6-318C334.4 80.7 152 223 152 398.7c0 175.7 182.4 318 407.4 318 19.3 0 39-1.2 58.4-3.2 61.7 37 148.3 77.8 222 89.5-12-25.9-24.8-56-31.5-89.5-11-3.9-42.1-16.8-62.1-45.2z" fill="#07c160"/></svg>
微信支付
</span>
</el-radio>
</el-radio-group>
</el-form-item>
</template>
</el-form>
</template>
<!-- 步骤2支付中 -->
<template v-else-if="step === 'paying'">
<div style="text-align:center;padding:10px 0">
<p style="font-size:15px;font-weight:500;margin:0 0 6px">支付金额</p>
<p style="font-size:32px;font-weight:700;color:#409eff;margin:0 0 20px">{{ (amountFen / 100).toFixed(2) }} </p>
<div v-if="codeUrl" style="margin-bottom:16px">
<img :src="qrCodeDataUrl" style="width:200px;height:200px;border:1px solid #e5e5e5;border-radius:6px" alt="支付二维码" />
<p style="font-size:12px;color:#999;margin-top:8px">请使用{{ channel === 'wechat' ? '微信' : '支付宝' }}扫码支付</p>
</div>
<div v-if="polling" style="margin:8px 0">
<el-icon class="is-loading" :size="16"><Loading /></el-icon>
<span style="font-size:13px;color:#909399;margin-left:6px">等待支付结果...</span>
</div>
<div v-if="payError" style="margin:8px 0">
<p style="color:#f56c6c;font-size:13px">{{ payError }}</p>
</div>
</div>
</template>
<!-- 步骤3成功 -->
<template v-else>
<div style="text-align:center;padding:20px 0">
<el-result icon="success" :title="successTitle" :sub-title="successSubtitle" />
</div>
</template>
<template #footer>
<template v-if="step === 'form'">
<el-button @click="visible = false">取消</el-button>
<el-button v-if="mode === 'admin-renew'" type="primary" :loading="submitting" :disabled="!selectedPricing" @click="handleAdminRenew">
确认续费
</el-button>
<el-button v-else type="primary" :loading="submitting" :disabled="submitDisabled" @click="handleSubmit">
去支付
</el-button>
</template>
<template v-else-if="step === 'paying'">
<el-button v-if="payError" type="primary" @click="step='form'">重新支付</el-button>
<el-button v-else disabled>支付中...</el-button>
</template>
<template v-else>
<el-button type="primary" @click="handleDone">完成</el-button>
</template>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { prepay, queryPaymentStatus } from '../api/payment.js'
import { createRenewOrder } from '../api/agent.js'
import { listRegionPricing, listRegionCascades } from '../api/regionPricing.js'
import QRCode from 'qrcode'
const props = defineProps({
modelValue: Boolean,
mode: { type: String, default: 'recharge' },
agentInfo: { type: Object, default: null }
})
const emit = defineEmits(['update:modelValue', 'success'])
const visible = ref(false)
const step = ref('form')
const amountInput = ref(100)
const channel = ref('alipay')
const submitting = ref(false)
const orderNo = ref('')
const codeUrl = ref('')
const qrCodeDataUrl = ref('')
const polling = ref(false)
const payError = ref('')
const duration = ref(1)
// 续费相关
const pricingList = ref([])
const cascadeOptions = ref([])
const cascaderRegion = ref([])
const selectedPricingId = ref(null)
const dialogTitle = computed(() => {
if (props.mode === 'recharge') return '充值'
return '代理商续费'
})
// selectedRegion = the city (second element of cascader)
const selectedRegion = computed(() => {
if (cascaderRegion.value && cascaderRegion.value.length >= 2) {
return cascaderRegion.value[1]
}
return ''
})
const availablePlans = computed(() => {
const region = selectedRegion.value
if (!region) return []
return pricingList.value.filter(p => p.region === region)
})
const selectedPricing = computed(() => {
if (!selectedPricingId.value) return null
return pricingList.value.find(p => p.id === selectedPricingId.value) || null
})
const amountFen = computed(() => {
if (props.mode === 'recharge') {
return Math.round(amountInput.value * 100)
}
if (selectedPricing.value) {
return selectedPricing.value.price * duration.value
}
return 0
})
const submitDisabled = computed(() => {
if (props.mode === 'recharge') return !amountInput.value || amountInput.value <= 0
return !selectedPricing.value
})
const successTitle = computed(() => props.mode === 'recharge' ? '充值成功' : '续费成功')
const successSubtitle = computed(() => props.mode === 'recharge' ? '余额已更新' : '到期时间已更新')
watch(() => props.modelValue, (val) => {
visible.value = val
})
async function handleOpen() {
step.value = 'form'
amountInput.value = 100
channel.value = 'alipay'
submitting.value = false
orderNo.value = ''
codeUrl.value = ''
qrCodeDataUrl.value = ''
polling.value = false
payError.value = ''
duration.value = 1
cascaderRegion.value = []
selectedPricingId.value = null
pricingList.value = []
cascadeOptions.value = []
if (props.mode !== 'recharge') {
try {
const [pricingRes, cascadeRes] = await Promise.all([
listRegionPricing({ page: 1, pageSize: -1 }),
listRegionCascades()
])
pricingList.value = pricingRes.list || []
cascadeOptions.value = (cascadeRes.list || []).map(item => ({
value: item.province,
label: item.province,
children: (item.cities || []).map(city => ({ value: city, label: city }))
}))
// preselect agent's current region if available
if (props.agentInfo?.province && props.agentInfo?.region && pricingList.value.some(p => p.region === props.agentInfo.region)) {
cascaderRegion.value = [props.agentInfo.province, props.agentInfo.region]
}
} catch (_) {}
}
}
function onRegionChange() {
selectedPricingId.value = null
if (availablePlans.value.length === 1) {
selectedPricingId.value = availablePlans.value[0].id
}
}
async function handleSubmit() {
if (submitDisabled.value) return
submitting.value = true
try {
const payload = {
amount: amountFen.value,
channel: channel.value
}
if (props.mode === 'agent-renew') {
payload.order_type = 'renewal'
payload.duration = duration.value
}
const res = await prepay(payload)
orderNo.value = res.order_no
codeUrl.value = res.code_url || ''
step.value = 'paying'
if (res.code_url) {
qrCodeDataUrl.value = await QRCode.toDataURL(res.code_url, { width: 200, margin: 2 })
}
startPolling()
} catch (e) {
// error handled by interceptor
} finally {
submitting.value = false
}
}
async function handleAdminRenew() {
submitting.value = true
try {
await createRenewOrder(props.agentInfo.id, duration.value, selectedPricingId.value)
step.value = 'paid'
emit('success')
} finally {
submitting.value = false
}
}
let pollTimer = null
function startPolling() {
polling.value = true
pollLoop()
}
async function pollLoop() {
if (!orderNo.value) return
try {
const res = await queryPaymentStatus(orderNo.value)
if (res.status === 'success') {
polling.value = false
step.value = 'paid'
clearTimeout(pollTimer)
emit('success')
return
}
if (res.status === 'failed') {
payError.value = '支付失败,请重试'
polling.value = false
clearTimeout(pollTimer)
return
}
} catch (e) {}
pollTimer = setTimeout(pollLoop, 3000)
}
function handleDone() {
visible.value = false
}
function handleClose() {
clearTimeout(pollTimer)
emit('update:modelValue', false)
}
</script>
+137
View File
@@ -0,0 +1,137 @@
<template>
<el-dialog v-model="visible" :title="title" width="700px" top="5vh" class="dialog-60h review-dialog" @closed="handleClose" destroy-on-close>
<div v-if="segments.length > 0" class="review-segments">
<div v-for="seg in segments" :key="seg.id" class="segment-item">
<div class="segment-header">
<span>{{ seg.segmentIdx + 1 }}</span>
<el-tag :type="statusType(seg.status)" size="small">{{ statusText(seg.status) }}</el-tag>
</div>
<div v-if="seg.errorMessage" class="segment-error">{{ seg.errorMessage }}</div>
<div class="segment-body">
<video v-if="toVideoUrl(seg.videoUrl)" :src="toVideoUrl(seg.videoUrl)" controls class="video-responsive" />
<el-empty v-else description="无视频" :image-size="60" />
<div v-if="seg.status === 'review' && seg.videoUrl" class="segment-actions">
<el-button size="small" type="primary" @click="handleConfirm(seg)">通过</el-button>
<el-button size="small" @click="handleReject(seg)">重做</el-button>
</div>
</div>
</div>
</div>
<el-empty v-else description="暂无任务数据" :image-size="80" />
<template #footer>
<span style="font-size:12px;color:#909399;margin-right:12px">
{{ segments.length }} · {{ segments.filter(s => s.status === 'completed').length }}已通过
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { continueSegment, feedbackSegment } from '@/api/segment'
import { getEpisodeTasks } from '@/api/episode'
import { toVideoUrl } from '@/utils'
const props = defineProps({
modelValue: Boolean,
episode: { type: Object, default: null },
tasks: { type: Array, default: () => [] },
currentTaskId: { type: [Number, String], default: null }
})
const emit = defineEmits(['update:modelValue', 'refresh'])
const visible = ref(false)
const tasks = ref([])
const currentTaskId = ref(null)
const title = computed(() => {
if (props.episode?.videoUrl && props.tasks.length === 0) {
return '预览 - ' + (props.episode.title || '')
}
return '预览审核'
})
const segments = computed(() => {
return tasks.value.slice().sort((a, b) => a.segmentIdx - b.segmentIdx)
})
watch(() => props.modelValue, async (val) => {
visible.value = val
if (val) {
if (props.episode?.videoUrl && props.tasks.length === 0) {
// Preview mode: show merged video
tasks.value = [{
id: -1,
videoUrl: props.episode.videoUrl,
status: 'completed',
segmentIdx: 0
}]
} else {
tasks.value = [...props.tasks]
currentTaskId.value = props.currentTaskId
}
}
})
function handleClose() {
emit('update:modelValue', false)
}
function statusType(status) {
return { completed: 'success', failed: 'danger', generating: 'primary', review: 'warning' }[status] || 'info'
}
function statusText(status) {
return { completed: '已通过', failed: '失败', generating: '生成中', review: '待审核' }[status] || status
}
async function handleConfirm(seg) {
try {
await continueSegment(seg.id)
ElMessage.success('已确认')
await reload()
emit('refresh')
} catch (e) {}
}
async function handleReject(seg) {
try {
const { value } = await ElMessageBox.prompt('请输入重做原因(将作为重新生成的提示词):', '重做', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /[\s\S]+/,
inputErrorMessage: '原因不能为空'
})
await feedbackSegment({ taskId: seg.id, feedback: value })
ElMessage.success('已提交,将根据反馈重新生成该段')
emit('refresh')
visible.value = false
} catch (e) {
if (e !== 'cancel') throw e
}
}
async function reload() {
if (!props.episode) return
try {
const data = await getEpisodeTasks(props.episode.id)
tasks.value = (data.tasks || []).slice()
currentTaskId.value = data.currentTaskId
} catch (e) {}
}
</script>
<style scoped>
.segment-item { margin-bottom:12px; border-bottom:1px solid #eee; padding-bottom:12px; }
.segment-header { font-size:13px; font-weight:600; color:#555; margin-bottom:6px; display:flex; align-items:center; gap:8px; }
.segment-error { font-size:12px; color:#e44; margin-bottom:4px; white-space:pre-wrap; }
.segment-body { display:flex; gap:12px; align-items:flex-start; }
.segment-actions { display:flex; flex-direction:column; gap:6px; padding-top:4px; }
@media (max-width:640px) {
.segment-body { flex-direction:column; }
.segment-actions { flex-direction:row; justify-content:flex-end; }
.review-dialog { width:94% !important; }
}
</style>
@@ -0,0 +1,238 @@
<template>
<div class="shot-editor">
<el-table :data="localShots" stripe size="small" max-height="450">
<el-table-column label="#" width="50">
<template #default="{ row }">{{ row.index }}</template>
</el-table-column>
<el-table-column label="开始" width="80">
<template #default="{ $index }">
<el-input v-model="localShots[$index].startTime" size="small" placeholder="00:00" maxlength="5" />
</template>
</el-table-column>
<el-table-column label="结束" width="80">
<template #default="{ $index }">
<el-input v-model="localShots[$index].endTime" size="small" placeholder="00:05" maxlength="5" />
</template>
</el-table-column>
<el-table-column label="事件描述" min-width="160">
<template #default="{ $index }">
<el-input v-model="localShots[$index].event" size="small" placeholder="该镜头的情节概要" />
</template>
</el-table-column>
<el-table-column label="台词" min-width="160">
<template #default="{ $index }">
<el-input v-model="localShots[$index].dialogue" size="small" placeholder="角色对白,多角色用「角色名:台词」格式" />
</template>
</el-table-column>
<el-table-column label="画外音" min-width="140">
<template #default="{ $index }">
<el-input v-model="localShots[$index].narration" size="small" placeholder="旁白解说" />
</template>
</el-table-column>
<el-table-column label="环境音" min-width="140">
<template #default="{ $index }">
<el-input v-model="localShots[$index].ambientSound" size="small" placeholder="背景音效" />
</template>
</el-table-column>
<el-table-column label="景别" width="100">
<template #default="{ $index }">
<el-select
v-model="localShots[$index].shotSize"
size="small"
placeholder="选择景别"
clearable
filterable
allow-create
>
<el-option label="远景" value="远景" />
<el-option label="全景" value="全景" />
<el-option label="中景" value="中景" />
<el-option label="近景" value="近景" />
<el-option label="特写" value="特写" />
</el-select>
</template>
</el-table-column>
<el-table-column label="运镜" width="130">
<template #default="{ $index }">
<el-select
v-model="localShots[$index].cameraMovement"
size="small"
placeholder="选择运镜"
clearable
filterable
allow-create
>
<el-option label="固定镜头" value="固定镜头" />
<el-option label="推" value="推" />
<el-option label="拉" value="拉" />
<el-option label="摇" value="摇" />
<el-option label="移" value="移" />
<el-option label="跟" value="跟" />
<el-option label="升" value="升" />
<el-option label="降" value="降" />
<el-option label="旋转" value="旋转" />
<el-option label="晃动" value="晃动" />
<el-option label="航拍" value="航拍" />
</el-select>
</template>
</el-table-column>
<el-table-column label="人物" width="140">
<template #default="{ $index }">
<el-select
v-model="localShots[$index].characters"
multiple
size="small"
placeholder="选择演员"
collapse-tags
collapse-tags-tooltip
filterable
>
<el-option
v-for="c in characters"
:key="c.name"
:label="c.name"
:value="c.name"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="场景" width="130">
<template #default="{ $index }">
<el-select
v-model="localShots[$index].scene"
size="small"
placeholder="选择场景"
clearable
filterable
>
<el-option
v-for="s in scenes"
:key="s.name"
:label="s.name"
:value="s.name"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="道具" width="140">
<template #default="{ $index }">
<el-select
v-model="localShots[$index].props"
multiple
size="small"
placeholder="选择道具"
collapse-tags
collapse-tags-tooltip
filterable
>
<el-option
v-for="p in props"
:key="p.name"
:label="p.name"
:value="p.name"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="60" fixed="right">
<template #default="{ $index }">
<el-button text type="danger" size="small" @click="removeShot($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="shot-toolbar">
<el-button size="small" @click="addShot">+ 添加镜头</el-button>
<span class="shot-total">总时长{{ totalDuration }}</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
characters: { type: Array, default: () => [] },
scenes: { type: Array, default: () => [] },
props: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue'])
const localShots = ref([])
const isInternalUpdate = ref(false)
watch(() => props.modelValue, (val) => {
isInternalUpdate.value = true
localShots.value = val.map(s => ({
...s,
characters: s.characters || [],
props: s.props || [],
scene: s.scene || ''
}))
}, { immediate: true, deep: true })
watch(localShots, () => {
if (isInternalUpdate.value) {
isInternalUpdate.value = false
return
}
emit('update:modelValue', localShots.value.map(s => ({ ...s })))
}, { deep: true })
function addShot() {
const idx = localShots.value.length + 1
const lastEnd = localShots.value.length > 0 ? localShots.value[localShots.value.length - 1].endTime : '00:00'
const [m, s] = (lastEnd || '00:00').split(':').map(Number)
const nextEnd = `${String(m).padStart(2, '0')}:${String(s + 5).padStart(2, '0')}`
localShots.value.push({
index: idx,
startTime: lastEnd,
endTime: nextEnd,
event: '',
dialogue: '',
narration: '',
ambientSound: '',
characters: [],
scene: '',
props: [],
shotSize: ''
})
reindex()
}
function removeShot(index) {
localShots.value.splice(index, 1)
reindex()
}
function reindex() {
localShots.value.forEach((s, i) => { s.index = i + 1 })
}
function parseTime(t) {
if (!t) return 0
const parts = t.split(':')
if (parts.length === 2) return parseInt(parts[0]) * 60 + parseInt(parts[1])
return parseInt(t) || 0
}
function formatTime(seconds) {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
const totalDuration = computed(() => {
if (localShots.value.length === 0) return '00:00'
const last = localShots.value[localShots.value.length - 1]
return formatTime(parseTime(last.endTime))
})
</script>
<style scoped>
.shot-editor { width: 100%; }
.shot-toolbar { display:flex; justify-content:space-between; align-items:center; margin-top:8px; }
.shot-total { font-size:13px; color:#909399; }
</style>
+242
View File
@@ -0,0 +1,242 @@
<template>
<el-dialog v-model="visible" title="我的模型配置" width="820px" top="10vh" destroy-on-close @closed="handleClose">
<el-form label-width="80px" label-position="top" size="small">
<el-row :gutter="20">
<el-col :span="12">
<h4 class="section-title">对话模型</h4>
<el-form-item label="模型">
<el-select v-model="selectedChatId" placeholder="请选择对话模型" clearable filterable style="width:100%">
<el-option v-for="m in chatModels" :key="m.modelConfigId" :label="m.modelName" :value="m.modelConfigId" />
</el-select>
<div v-if="selectedChatModel" class="form-tip">模型并发数: {{ selectedChatModel.concurrencyCount }}</div>
</el-form-item>
<template v-if="selectedChatId">
<el-form-item label="API Key">
<el-input v-model="chatForm.apiKey" type="password" show-password placeholder="sk-..." />
</el-form-item>
<el-row :gutter="10">
<el-col :span="12">
<el-form-item label="端点地址">
<el-input v-model="chatForm.endpointUrl" placeholder="https://api.openai.com" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="接口地址">
<el-input v-model="chatForm.requestPath" placeholder="/v1/chat/completions" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="12">
<el-form-item label="Temperature">
<el-slider v-model="chatForm.temperature" :min="0" :max="2" :step="0.05" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Max Tokens">
<el-input-number v-model="chatForm.maxTokens" :min="256" :max="selectedChatMaxTokens" :step="512" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="12">
<el-form-item label="并发数">
<el-input-number v-model="chatForm.concurrencyCount" :min="1" :max="100" style="width:100%" />
<div class="el-form-item__tip">并发数必须大于0</div>
</el-form-item>
</el-col>
<el-col :span="12" />
</el-row>
<div class="form-tip">Temperature: {{ chatForm.temperature.toFixed(2) }} | Max Tokens上限 {{ selectedChatMaxTokens || 'N/A' }} | 并发数: {{ chatForm.concurrencyCount }}</div>
</template>
</el-col>
<el-col :span="12">
<h4 class="section-title">视频生成模型</h4>
<el-form-item label="模型">
<el-select v-model="selectedVideoId" placeholder="请选择视频生成模型" clearable filterable style="width:100%">
<el-option v-for="m in videoModels" :key="m.modelConfigId" :label="m.modelName" :value="m.modelConfigId" />
</el-select>
<div v-if="selectedVideoModel" class="form-tip">模型并发数: {{ selectedVideoModel.concurrencyCount }}</div>
</el-form-item>
<template v-if="selectedVideoId">
<el-form-item label="API Key">
<el-input v-model="videoForm.apiKey" type="password" show-password placeholder="sk-..." />
</el-form-item>
<el-row :gutter="10">
<el-col :span="12">
<el-form-item label="端点地址">
<el-input v-model="videoForm.endpointUrl" placeholder="https://api.example.com" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="接口地址">
<el-input v-model="videoForm.requestPath" placeholder="/v1/video/generate" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="回调接口地址">
<el-input v-model="videoForm.callbackPath" placeholder="/v1/task/status" />
</el-form-item>
<el-form-item label="Temperature">
<el-slider v-model="videoForm.temperature" :min="0" :max="2" :step="0.05" style="width:100%" />
</el-form-item>
<el-form-item label="并发数">
<el-input-number v-model="videoForm.concurrencyCount" :min="1" :max="100" style="width:100%" />
<div class="el-form-item__tip">并发数必须大于0</div>
</el-form-item>
<div class="form-tip">当前值: {{ videoForm.temperature.toFixed(2) }} | 并发数: {{ videoForm.concurrencyCount }}</div>
</template>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { getUserModelList, saveUserConfig } from '@/api/userConfig'
const props = defineProps({
modelValue: Boolean
})
const emit = defineEmits(['update:modelValue'])
const visible = ref(false)
const saving = ref(false)
const loading = ref(false)
const allModels = ref([])
const chatModels = computed(() => allModels.value.filter(m => m.modelType === 'chat'))
const videoModels = computed(() => allModels.value.filter(m => m.modelType === 'video'))
const selectedChatId = ref(null)
const selectedVideoId = ref(null)
const chatForm = reactive({ apiKey: '', endpointUrl: '', requestPath: '', temperature: 0.85, maxTokens: 4096, concurrencyCount: 0 })
const videoForm = reactive({ apiKey: '', endpointUrl: '', requestPath: '', callbackPath: '', temperature: 0.85, concurrencyCount: 0 })
const selectedChatModel = computed(() => {
return allModels.value.find(m => m.modelConfigId === selectedChatId.value) || null
})
const selectedVideoModel = computed(() => {
return allModels.value.find(m => m.modelConfigId === selectedVideoId.value) || null
})
const selectedChatMaxTokens = computed(() => {
return selectedChatModel.value?.systemMaxTokens || 4096
})
function findModelById(id) {
return allModels.value.find(m => m.modelConfigId === id) || null
}
watch(selectedChatId, (id) => {
const m = findModelById(id)
chatForm.apiKey = m?.userApiKey || ''
chatForm.endpointUrl = m?.endpointUrl || ''
chatForm.requestPath = m?.interfacePath || ''
chatForm.temperature = m?.temperature ?? 0.85
chatForm.maxTokens = m?.userMaxTokens || m?.systemMaxTokens || 4096
chatForm.concurrencyCount = m?.concurrencyCount || 1
})
watch(selectedVideoId, (id) => {
const m = findModelById(id)
videoForm.apiKey = m?.userApiKey || ''
videoForm.endpointUrl = m?.endpointUrl || ''
videoForm.requestPath = m?.interfacePath || ''
videoForm.callbackPath = m?.callbackPath || ''
videoForm.temperature = m?.temperature ?? 0.85
videoForm.concurrencyCount = m?.concurrencyCount || 1
})
watch(() => props.modelValue, async (val) => {
visible.value = val
if (val) {
await loadData()
}
})
function handleClose() {
emit('update:modelValue', false)
}
async function loadData() {
loading.value = true
try {
const res = await getUserModelList({ page: 1, pageSize: 999 })
allModels.value = res.list || []
const activeChat = allModels.value.find(m => m.modelType === 'chat' && m.configured)
selectedChatId.value = activeChat?.modelConfigId || null
const activeVideo = allModels.value.find(m => m.modelType === 'video' && m.configured)
selectedVideoId.value = activeVideo?.modelConfigId || null
} catch (e) {
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
// 校验并发数必须大于0
if ((selectedChatId.value && (!chatForm.concurrencyCount || chatForm.concurrencyCount < 1)) ||
(selectedVideoId.value && (!videoForm.concurrencyCount || videoForm.concurrencyCount < 1))) {
ElMessage.warning('并发数必须大于0')
saving.value = false
return
}
const configs = []
if (selectedChatId.value) {
configs.push({
modelConfigId: selectedChatId.value,
apiKey: chatForm.apiKey,
endpointUrl: chatForm.endpointUrl,
requestPath: chatForm.requestPath,
temperature: chatForm.temperature,
maxTokens: chatForm.maxTokens,
concurrencyCount: chatForm.concurrencyCount
})
}
if (selectedVideoId.value) {
configs.push({
modelConfigId: selectedVideoId.value,
apiKey: videoForm.apiKey,
endpointUrl: videoForm.endpointUrl,
requestPath: videoForm.requestPath,
callbackPath: videoForm.callbackPath,
temperature: videoForm.temperature,
concurrencyCount: videoForm.concurrencyCount
})
}
await saveUserConfig({ configs })
ElMessage.success('保存成功')
visible.value = false
} catch (e) {
} finally {
saving.value = false
}
}
</script>
<style scoped>
.section-title {
margin: 0 0 4px 0;
font-size: 14px;
font-weight: 600;
color: #111;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import './styles/dialog.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(ElementPlus, { locale: zhCn })
app.use(router)
app.mount('#app')
+93
View File
@@ -0,0 +1,93 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth.js'
const roleFirstRoute = {
admin: '/admin/agents',
agent: '/agent/customers',
customer: '/drama'
}
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue')
},
{
path: '/',
component: () => import('../views/Layout.vue'),
redirect: () => {
try {
const user = JSON.parse(localStorage.getItem('user') || '{}')
return roleFirstRoute[user.role] || '/drama'
} catch {
return '/drama'
}
},
children: [
{
path: 'drama',
name: 'DramaList',
meta: { roles: ['customer'], title: '视频管理' },
component: () => import('../views/DramaList.vue')
},
{
path: 'admin/agents',
name: 'AgentList',
meta: { roles: ['admin'], title: '代理商管理' },
component: () => import('../views/admin/AgentList.vue')
},
{
path: 'admin/customers',
name: 'AdminCustomerList',
meta: { roles: ['admin'], title: '客户列表' },
component: () => import('../views/admin/CustomerList.vue')
},
{
path: 'agent/customers',
name: 'AgentCustomerList',
meta: { roles: ['agent'], title: '我的客户' },
component: () => import('../views/agent/CustomerList.vue')
},
{
path: 'customer/profile',
name: 'CustomerProfile',
meta: { roles: ['customer'], title: '我的账户' },
component: () => import('../views/customer/Profile.vue')
},
{
path: 'customer/transactions',
name: 'CustomerTransactions',
meta: { roles: ['customer'], title: '交易记录' },
component: () => import('../views/customer/TransactionList.vue')
}
]
}
]
const router = createRouter({
// 使用 hash 路由:前后端合并部署后 SPA 路由(/drama、/customer/* 等)与后端 API
// 前缀重叠,history 模式下刷新会命中 API 路由而非页面;hash 模式页面始终请求 /,
// 由后端静态服务返回 index.html,API 路径保持后端鉴权
history: createWebHashHistory(),
routes
})
router.beforeEach((to, from, next) => {
const auth = useAuthStore()
if (to.path !== '/login' && !auth.isLoggedIn) {
next('/login')
return
}
if (to.path === '/login' && auth.isLoggedIn) {
next('/')
return
}
if (to.meta.roles && !to.meta.roles.includes(auth.role)) {
next('/login')
return
}
next()
})
export default router
+36
View File
@@ -0,0 +1,36 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login } from '../api/auth.js'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '')
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
const isLoggedIn = computed(() => !!token.value)
const role = computed(() => user.value?.role || '')
const isAdmin = computed(() => role.value === 'admin')
const isAgent = computed(() => role.value === 'agent')
const isCustomer = computed(() => role.value === 'customer')
async function doLogin(account, password) {
const res = await login({ account, password })
token.value = res.token
user.value = res.user
localStorage.setItem('token', res.token)
localStorage.setItem('user', JSON.stringify(res.user))
return res
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
function getUser() {
return user.value
}
return { token, user, isLoggedIn, role, isAdmin, isAgent, isCustomer, doLogin, logout, getUser }
})
+250
View File
@@ -0,0 +1,250 @@
/* 全局重置 */
body { margin:0; }
/* 所有弹窗统一高度 80vh */
.el-dialog.dialog-60h {
height: 80vh;
display: flex;
flex-direction: column;
}
.el-dialog.dialog-60h .el-dialog__body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
display: flex;
flex-direction: column;
}
.el-dialog.dialog-60h .el-dialog__footer {
flex-shrink: 0;
}
/* 手机端弹窗全宽 */
@media (max-width: 640px) {
.el-dialog.dialog-60h {
width: 94% !important;
max-width: 94% !important;
}
.el-dialog {
min-width: auto !important;
}
.el-dialog__body {
padding: 12px;
}
}
/* 响应式 tab 滚动 */
.el-tabs--border-card > .el-tabs__header .el-tabs__nav-wrap {
overflow-x: auto;
overflow-y: hidden;
flex-wrap: nowrap;
}
.el-tabs--border-card > .el-tabs__header .el-tabs__nav {
display: inline-flex;
white-space: nowrap;
float: none;
}
/* 内嵌弹窗手机适配 */
.inner-dialog-mobile {
max-width: 94vw !important;
width: 94vw !important;
}
/* 分页响应式 */
.pagination-wrap .el-pagination {
flex-wrap: wrap;
justify-content: center;
}
/* 视频响应式 */
.video-responsive {
max-width: 360px;
max-height: 240px;
border-radius: 8px;
width: 100%;
height: auto;
}
@media (max-width: 640px) {
.video-responsive {
max-width: 100%;
max-height: 200px;
}
}
/* ========== B&W Theme for Element Plus Components ========== */
/* Dialog header/footer borders */
.el-dialog {
--el-dialog-bg-color: #fff;
}
.el-dialog__header {
border-bottom: 1px solid #e5e5e5;
padding: 14px 20px;
margin-right: 0;
}
.el-dialog__title {
font-size: 15px;
font-weight: 600;
color: #111;
}
.el-dialog__body {
padding: 16px 20px;
}
.el-dialog__footer {
border-top: 1px solid #e5e5e5;
padding: 10px 20px;
}
/* Tabs (border-card) */
.el-tabs--border-card {
background: #fff;
border: 1px solid #e5e5e5;
}
.el-tabs--border-card > .el-tabs__header {
background: #fafafa;
border-bottom: 1px solid #e5e5e5;
}
.el-tabs--border-card > .el-tabs__header .el-tabs__item {
color: #888;
border: none;
transition: color 0.15s;
}
.el-tabs--border-card > .el-tabs__header .el-tabs__item.is-active {
color: #000;
background: #fff;
font-weight: 500;
}
.el-tabs--border-card > .el-tabs__header .el-tabs__item:not(.is-active):hover {
color: #333;
}
/* Buttons */
.el-button--primary {
--el-button-bg-color: #000;
--el-button-border-color: #000;
--el-button-text-color: #fff;
--el-button-hover-bg-color: #333;
--el-button-hover-border-color: #333;
--el-button-hover-text-color: #fff;
--el-button-active-bg-color: #000;
--el-button-active-border-color: #000;
}
.el-button--default {
--el-button-bg-color: #fff;
--el-button-border-color: #e5e5e5;
--el-button-text-color: #333;
--el-button-hover-bg-color: #f5f5f5;
--el-button-hover-border-color: #ccc;
--el-button-hover-text-color: #000;
}
/* Input */
.el-input__wrapper {
box-shadow: 0 0 0 1px #e5e5e5 inset !important;
}
.el-input__wrapper:hover {
box-shadow: 0 0 0 1px #bbb inset !important;
}
.el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px #000 inset !important;
}
.el-input__inner::placeholder {
color: #aaa;
}
/* Input Number */
.el-input-number__increase,
.el-input-number__decrease {
background: #fafafa;
color: #666;
}
.el-input-number__increase:hover,
.el-input-number__decrease:hover {
background: #f0f0f0;
color: #000;
}
/* Select Dropdown */
.el-select-dropdown {
border: 1px solid #e5e5e5;
}
.el-select-dropdown__item.hover,
.el-select-dropdown__item:hover {
background: #f5f5f5;
}
.el-select-dropdown__item.is-selected {
color: #000;
font-weight: 500;
}
/* Pagination */
.el-pagination {
--el-color-primary: #000;
--el-pagination-button-bg-color: #fff;
--el-pagination-button-color: #333;
--el-pagination-button-hover-bg-color: #f5f5f5;
--el-pagination-button-active-bg-color: #000;
--el-pagination-button-active-color: #fff;
--el-pagination-border-radius: 4px;
--el-pagination-font-size: 12px;
}
.el-pagination button {
border: 1px solid #e5e5e5;
border-radius: 4px;
min-width: 28px;
height: 28px;
}
.el-pagination button:hover {
border-color: #ccc;
}
.el-pagination .el-pager li {
border: 1px solid #e5e5e5;
border-radius: 4px;
margin: 0 2px;
min-width: 28px;
height: 28px;
font-size: 12px;
color: #333;
}
.el-pagination .el-pager li:hover {
border-color: #ccc;
background: #f5f5f5;
}
.el-pagination .el-pager li.is-active {
border-color: #000;
background: #000;
color: #fff;
}
.el-pagination .el-pagination__sizes .el-input__wrapper {
box-shadow: 0 0 0 1px #e5e5e5 inset !important;
}
/* Avatar */
.el-avatar {
--el-avatar-bg-color: #f0f0f0;
--el-avatar-text-color: #666;
}
/* Switch */
.el-switch__core {
border-color: #ddd;
background: #ddd;
}
.el-switch.is-checked .el-switch__core {
border-color: #000;
background: #000;
}
/* Form label */
.el-form-item__label {
color: #333;
font-weight: 500;
}
/* Inner dialog (nested) adjustments */
.inner-dialog-mobile .el-dialog__body {
padding: 12px;
}
+8
View File
@@ -0,0 +1,8 @@
export function toVideoUrl(path) {
if (!path) return ''
if (path.indexOf('://') > 0) return path
if (path.startsWith('workspace/')) return '/' + path
const wsIdx = path.indexOf('/workspace/')
if (wsIdx >= 0) return path.substring(wsIdx)
return path
}
+333
View File
@@ -0,0 +1,333 @@
<template>
<div class="projects" v-loading="loading">
<div class="toolbar">
<div class="bar-search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15" class="search-icon"><path d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"/></svg>
<input v-model="dramaSearch" placeholder="搜索项目..." @keyup.enter="handleSearch" />
</div>
<div style="display:flex;gap:6px"></div>
</div>
<div class="projects-scroll">
<div v-if="list.length === 0 && !loading" class="empty">
<div class="empty-visual">
<svg viewBox="0 0 80 80" fill="none" width="80" height="80">
<rect x="12" y="18" width="56" height="44" rx="6" stroke="#d4d4d8" stroke-width="1.5" fill="#fafafa"/>
<circle cx="40" cy="40" r="12" stroke="#d4d4d8" stroke-width="1.5" fill="#f4f4f5"/>
<path d="M34 40l4 3 7-6" stroke="#a1a1aa" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h3 class="empty-title">{{ searchKeyword ? '没找到匹配的项目' : '开始你的第一个作品' }}</h3>
<p class="empty-desc">{{ searchKeyword ? '试试其他关键词' : '创建一个视频项目,从这里开始' }}</p>
<button v-if="!searchKeyword" class="btn-primary" @click="openCreateDrama()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
创建项目
</button>
</div>
<div v-if="list.length > 0" class="grid">
<div
v-for="item in list"
:key="item.id"
class="card"
:style="{ '--accent': accentColor(item.type) }"
@click="openEpisode(item)"
>
<div class="card-accent"></div>
<div class="card-body">
<div class="card-head">
<span class="card-badge">{{ item.type || '短剧' }}</span>
<button class="card-more" @click.stop @click.prevent="onCardMenu($event, item)">
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
</div>
<h3 class="card-title">{{ item.title }}</h3>
<div class="card-stats">
<span>{{ item.epCount || 0 }} </span>
<span class="dot">·</span>
<span>{{ item.episodeDuration || '-' }}s/</span>
<span v-if="item.aspectRatio" class="dot">·</span>
<span v-if="item.aspectRatio">{{ item.aspectRatio }}</span>
</div>
</div>
</div>
<div class="card card-add" @click="openCreateDrama()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="28" height="28"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
<span>新建项目</span>
</div>
</div>
</div>
<div v-if="total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="loadList"
@size-change="onSizeChange"
/>
</div>
<Teleport to="body">
<div v-if="contextMenu.show" class="ctx-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }" @click.stop>
<div class="ctx-item" @click="openEditDrama(contextMenu.item)">编辑</div>
<div class="ctx-item ctx-danger" @click="handleDelete(contextMenu.item)">删除</div>
</div>
<div v-if="contextMenu.show" class="ctx-overlay" @click="contextMenu.show = false"></div>
</Teleport>
<DramaEditDialog v-model="dramaEditVisible" :drama-id="editingDramaId" :initial-tab="dramaEditInitialTab" @saved="onDramaCreated" @update:model-value="onDramaEditClose" />
<EpisodeManageDialog v-model="episodeVisible" :drama-id="episodeDramaId" :drama-title="episodeDramaTitle" @refresh="loadList" @edit-actors="handleEditActors" />
<ConfigDialog v-model="configVisible" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listDramas, deleteDrama } from '@/api/drama'
import DramaEditDialog from '@/components/DramaEditDialog.vue'
import EpisodeManageDialog from '@/components/EpisodeManageDialog.vue'
import ConfigDialog from '@/components/ConfigDialog.vue'
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const dramaSearch = ref('')
const searchKeyword = ref('')
const dramaEditVisible = ref(false)
const editingDramaId = ref(null)
const dramaEditInitialTab = ref('basic')
const episodeVisible = ref(false)
const episodeDramaId = ref(null)
const episodeDramaTitle = ref('')
const configVisible = ref(false)
const contextMenu = reactive({ show: false, x: 0, y: 0, item: null })
onMounted(() => {
loadList()
document.addEventListener('click', closeContextMenu)
})
onUnmounted(() => document.removeEventListener('click', closeContextMenu))
function closeContextMenu() { contextMenu.show = false }
async function loadList() {
loading.value = true
try {
const data = await listDramas(page.value, pageSize.value, searchKeyword.value)
list.value = data.list || []
total.value = data.total || 0
} catch {} finally {
loading.value = false
}
}
function handleSearch() {
searchKeyword.value = dramaSearch.value.trim()
page.value = 1
loadList()
}
function onSizeChange(size) {
pageSize.value = size
page.value = 1
loadList()
}
function openCreateDrama() {
editingDramaId.value = null
dramaEditVisible.value = true
}
function openEditDrama(item) {
contextMenu.show = false
editingDramaId.value = item.id
dramaEditVisible.value = true
}
function onDramaCreated() { loadList() }
function onDramaEditClose(val) { if (!val) loadList() }
function handleEditActors(dramaId) {
episodeVisible.value = false
editingDramaId.value = dramaId
dramaEditInitialTab.value = 'actor'
dramaEditVisible.value = true
}
async function handleDelete(item) {
contextMenu.show = false
try {
await ElMessageBox.confirm(`确定删除《${item.title}》?`, '确认', { type: 'warning' })
await deleteDrama(item.id)
ElMessage.success('已删除')
await loadList()
} catch (e) { if (e !== 'cancel') throw e }
}
function onCardMenu(e, item) {
e.stopPropagation()
const pad = 8
let x = e.clientX, y = e.clientY
if (x + 120 > window.innerWidth) x = window.innerWidth - 120 - pad
if (y + 72 > window.innerHeight) y = window.innerHeight - 72 - pad
contextMenu.x = x
contextMenu.y = y
contextMenu.item = item
contextMenu.show = true
}
function openEpisode(item) {
episodeDramaId.value = item.id
episodeDramaTitle.value = item.title
episodeVisible.value = true
}
function accentColor(type) {
if (type === '漫剧') return '#999'
if (type === '广告视频') return '#bbb'
return '#d4d4d4'
}
</script>
<style scoped>
.projects {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px 28px 0;
min-height: 0;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 10px;
}
.bar-search {
display: flex;
align-items: center;
gap: 6px;
background: #fff;
border-radius: 4px;
padding: 0 10px;
height: 34px;
border: 1px solid #e5e5e5;
transition: border 0.15s;
}
.bar-search:focus-within { border-color: #888; }
.search-icon { color: #aaa; flex-shrink: 0; }
.bar-search input {
border: none;
background: none;
outline: none;
font-size: 13px;
color: #333;
width: 200px;
}
.bar-search input::placeholder { color: #bbb; }
.btn-primary {
display: inline-flex;
align-items: center;
gap: 5px;
height: 34px;
padding: 0 14px;
border-radius: 4px;
border: none;
background: #000;
color: #fff;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn-primary:hover { background: #333; }
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 4px;
border: 1px solid #e5e5e5;
background: #fff;
color: #666;
cursor: pointer;
transition: all 0.15s;
}
.btn-icon:hover { border-color: #bbb; color: #333; }
.projects-scroll {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
text-align: center;
}
.empty-visual { margin-bottom: 16px; opacity: 0.4; }
.empty-title { font-size: 15px; font-weight: 500; color: #888; margin: 0 0 4px; }
.empty-desc { font-size: 13px; color: #aaa; margin: 0 0 20px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
}
.card {
background: #fff;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid #e5e5e5;
}
.card:hover { border-color: #bbb; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.card-accent { height: 2px; background: var(--accent, #ccc); }
.card-body { padding: 12px 14px 14px; }
.card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.card-badge { font-size: 10px; font-weight: 500; color: #999; border: 1px solid #e5e5e5; padding: 1px 7px; border-radius: 3px; letter-spacing: 0.04em; }
.card-more { background: none; border: none; cursor: pointer; color: #ccc; padding: 4px; margin: -4px; border-radius: 4px; display: flex; transition: all 0.15s; }
.card-more:hover { background: #f0f0f0; color: #666; }
.card-title { font-size: 14px; font-weight: 500; color: #111; margin: 0 0 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-stats { font-size: 11px; color: #aaa; display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
.dot { opacity: 0.3; }
.card-add { border: 1px dashed #d4d4d4; min-height: 105px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: #bbb; font-size: 13px; background: transparent; }
.card-add:hover { border-color: #888; color: #666; background: #fafafa; }
.pagination-wrap {
display: flex;
justify-content: center;
padding: 12px 0;
background: #fff;
border-top: 1px solid #e5e5e5;
flex-shrink: 0;
margin: 0 -28px;
padding-left: 28px;
padding-right: 28px;
}
.ctx-overlay { position: fixed; inset: 0; z-index: 999; }
.ctx-menu { position: fixed; z-index: 1000; background: #fff; border-radius: 6px; box-shadow: 0 4px 20px rgba(0,0,0,0.12); padding: 4px; min-width: 110px; border: 1px solid #e5e5e5; }
.ctx-item { padding: 7px 10px; font-size: 13px; color: #333; border-radius: 4px; cursor: pointer; transition: background 0.12s; }
.ctx-item:hover { background: #f5f5f5; }
.ctx-danger { color: #ef4444; }
.ctx-danger:hover { background: #fef2f2; }
:deep(.el-loading-mask) { background: rgba(245,245,245,0.7); }
@media (max-width: 768px) {
.projects { padding: 16px 16px 0; }
.bar-search input { width: 100px; }
.grid { grid-template-columns: 1fr; }
.pagination-wrap { margin: 0 -16px; padding-left: 16px; padding-right: 16px; }
}
</style>
+191
View File
@@ -0,0 +1,191 @@
<template>
<div class="studio">
<!-- Top Bar -->
<header class="studio-bar">
<div class="bar-left">
<div class="bar-logo">
<svg viewBox="0 0 32 32" fill="none" width="22" height="22">
<rect x="4" y="8" width="24" height="18" rx="4" stroke="currentColor" stroke-width="1.8"/>
<path d="M13 14l7 4.5-7 4.5V14z" fill="currentColor" opacity="0.8"/>
</svg>
<span class="bar-title">Video Factory</span>
</div>
</div>
<div class="bar-center">{{ pageTitle }}</div>
<div class="bar-right">
<!-- 代理商到期时间 - 点击弹出续费 -->
<button v-if="auth.isAgent && auth.user?.expired_at" class="btn-icon btn-expiry" @click="openRenew">
到期:{{ auth.user.expired_at }}
</button>
<el-dropdown trigger="click" v-if="auth.isLoggedIn">
<button class="btn-icon" style="color:#fff;display:flex;align-items:center;gap:4px;padding:0 8px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="16" height="16">
<path d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"/>
</svg>
<span v-if="auth.isCustomer && auth.user?.balance != null" style="font-size:12px;color:#ffd666;margin-right:4px">{{ (auth.user.balance / 100).toFixed(2) }}</span>
<span style="font-size:12px;font-weight:500">{{ auth.user?.name || auth.user?.username }}</span>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="auth.isAdmin" @click="navigate('/admin/agents')">代理商管理</el-dropdown-item>
<el-dropdown-item v-if="auth.isAdmin" @click="navigate('/admin/customers')">客户列表</el-dropdown-item>
<el-dropdown-item v-if="auth.isAdmin" @click="configVisible = true">系统配置</el-dropdown-item>
<el-dropdown-item v-if="auth.isAdmin" divided @click="handleLogout">退出登录</el-dropdown-item>
<el-dropdown-item v-if="auth.isAgent" @click="navigate('/agent/customers')">我的客户</el-dropdown-item>
<el-dropdown-item v-if="auth.isAgent" divided @click="handleLogout">退出登录</el-dropdown-item>
<el-dropdown-item v-if="auth.isCustomer" @click="navigate('/drama')">视频管理</el-dropdown-item>
<el-dropdown-item v-if="auth.isCustomer" @click="navigate('/customer/profile')">我的账户</el-dropdown-item>
<el-dropdown-item v-if="auth.isCustomer" divided @click="userConfigVisible = true">模型配置</el-dropdown-item>
<el-dropdown-item v-if="auth.isCustomer" divided @click="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<button v-else class="btn-primary" @click="navigate('/login')">登录</button>
</div>
</header>
<!-- Content -->
<main class="main-content">
<router-view />
</main>
<ConfigDialog v-model="configVisible" />
<UserConfigDialog v-model="userConfigVisible" />
<PaymentDialog v-model="showRenew" mode="agent-renew" :agent-info="renewAgentInfo" @success="onRenewSuccess" />
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth.js'
import ConfigDialog from '@/components/ConfigDialog.vue'
import UserConfigDialog from '@/components/UserConfigDialog.vue'
import PaymentDialog from '@/components/PaymentDialog.vue'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const configVisible = ref(false)
const userConfigVisible = ref(false)
const pageTitle = computed(() => route.meta.title || '')
// 续费弹窗
const showRenew = ref(false)
const renewAgentInfo = ref(null)
function openRenew() {
renewAgentInfo.value = {
id: auth.user?.id,
expiredAt: auth.user?.expired_at,
province: auth.user?.province || '',
region: auth.user?.region
}
showRenew.value = true
}
function onRenewSuccess() {
location.reload()
}
function navigate(path) {
router.push(path)
}
function handleLogout() {
auth.logout()
router.push('/login')
}
</script>
<style scoped>
.studio {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.studio-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
height: 48px;
background: #000;
position: sticky;
top: 0;
z-index: 10;
}
.bar-logo {
display: flex;
align-items: center;
gap: 8px;
color: #fff;
}
.bar-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
}
.bar-right {
display: flex;
align-items: center;
gap: 8px;
}
.bar-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
color: #fff;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
height: 30px;
border-radius: 4px;
border: none;
background: transparent;
color: #999;
cursor: pointer;
transition: all 0.15s;
}
.btn-icon:hover { background: #2a2a2a; color: #fff; }
.btn-expiry {
font-size: 11px;
color: #ffd666;
padding: 0 8px;
white-space: nowrap;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 5px;
height: 30px;
padding: 0 12px;
border-radius: 4px;
border: none;
background: #fff;
color: #000;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn-primary:hover { background: #e5e5e5; }
.main-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
</style>
+94
View File
@@ -0,0 +1,94 @@
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<h2 style="text-align:center">视频工厂 - 登录</h2>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px" @keyup.enter="handleLogin">
<el-form-item label="账号" prop="account">
<el-input v-model="form.account" placeholder="用户名/手机号" clearable />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" style="width:100%" @click="handleLogin">
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '../stores/auth.js'
const router = useRouter()
const auth = useAuthStore()
const formRef = ref(null)
const loading = ref(false)
const form = reactive({
account: '',
password: ''
})
const rules = {
account: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 5, max: 18, message: '账号长度5-18位', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 18, message: '密码长度6-18位', trigger: 'blur' },
{ pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]).{6,18}$/, message: '密码必须包含大写字母、小写字母、数字和特殊符号', trigger: 'blur' }
]
}
async function handleLogin() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await auth.doLogin(form.account, form.password)
ElMessage.success('登录成功')
router.push('/')
} catch {
// error already handled by request interceptor
} finally {
loading.value = false
}
}
onMounted(async () => {
const defaultUser = import.meta.env.VITE_DEFAULT_USERNAME
const defaultPwd = import.meta.env.VITE_DEFAULT_PASSWORD
if (defaultUser && defaultPwd) {
form.account = defaultUser
form.password = defaultPwd
loading.value = true
try {
await auth.doLogin(defaultUser, defaultPwd)
router.push('/')
} catch {
loading.value = false
}
}
})
</script>
<style scoped>
.login-container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f0f2f5;
}
.login-card {
width: 420px;
}
</style>
+322
View File
@@ -0,0 +1,322 @@
<template>
<div class="page">
<div class="page-body">
<div class="search-bar">
<el-input v-model="filters.keyword" placeholder="名称/账号" clearable style="width:160px" />
<el-input v-model="filters.phone" placeholder="手机号" clearable style="width:140px" />
<el-cascader v-model="filters.cascaderRegion" :options="cascadeOptions" style="width:200px" placeholder="所在地区" clearable @change="onFilterCascaderChange" @click="lazyLoadCascades" />
<el-date-picker v-model="filters.expiryRange" type="daterange" range-separator="至" start-placeholder="到期开始" end-placeholder="到期结束" value-format="YYYY-MM-DD" style="width:220px" />
<el-button type="primary" @click="doSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
<span class="search-spacer"></span>
<button class="btn-primary" @click="showCreate = true; lazyLoadCascades()">新建代理商</button>
</div>
<div class="table-wrapper">
<el-table :data="list" border stripe v-loading="loading" height="100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="username" label="账号" width="140" />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="name" label="名称" />
<el-table-column label="受保护" width="80" align="center">
<template #default="{ row }">{{ row.region_protected ? '是' : '否' }}</template>
</el-table-column>
<el-table-column label="省份" width="120">
<template #default="{ row }">{{ row.province || '-' }}</template>
</el-table-column>
<el-table-column label="区域" width="120">
<template #default="{ row }">{{ row.region || '-' }}</template>
</el-table-column>
<el-table-column label="客户上限" width="100" align="right">
<template #default="{ row }">{{ row.max_customers ?? '-' }}</template>
</el-table-column>
<el-table-column label="到期" width="170">
<template #default="{ row }">{{ row.expired_at || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" @click="handleRenew(row)">续费</el-button>
<el-button size="small" @click="handleViewRenewals(row)">续费记录</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="total > 0" class="pagination-wrap">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" :page-sizes="[20, 50, 100]" layout="total, sizes, prev, pager, next" background @current-change="fetchData" @size-change="onSizeChange" />
</div>
</div>
<el-dialog v-model="showCreate" title="新建代理商" width="500px">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="90px">
<el-form-item label="账号" prop="username"><el-input v-model="createForm.username" /></el-form-item>
<el-form-item label="密码" prop="password"><el-input v-model="createForm.password" type="password" /></el-form-item>
<el-form-item label="手机号" prop="phone"><el-input v-model="createForm.phone" /></el-form-item>
<el-form-item label="名称" prop="name"><el-input v-model="createForm.name" /></el-form-item>
<el-form-item label="区域" prop="region">
<el-cascader v-model="createForm.cascaderRegion" :options="cascadeOptions" style="width:100%" placeholder="请选择省/市" @change="onCascaderChange" />
</el-form-item>
<el-form-item label="地区保护">
<el-switch v-model="createForm.region_protected" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="handleCreate">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="showEdit" title="编辑代理商" width="500px">
<el-form ref="editFormRef" :model="editForm" :rules="editRules" label-width="90px">
<el-form-item label="名称" prop="name"><el-input v-model="editForm.name" /></el-form-item>
<el-form-item label="手机号" prop="phone"><el-input v-model="editForm.phone" /></el-form-item>
<el-form-item label="区域" prop="region">
<el-cascader v-model="editForm.cascaderRegion" :options="cascadeOptions" style="width:100%" placeholder="请选择省/市" @change="onEditCascaderChange" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" :loading="updating" @click="handleUpdate">保存</el-button>
</template>
</el-dialog>
<PaymentDialog v-model="showRenew" mode="admin-renew" :agent-info="renewAgentInfo" @success="onRenewSuccess" />
<el-dialog v-model="showRenewals" title="续费记录" width="800px">
<el-table :data="renewalList" stripe v-loading="renewalLoading" max-height="460">
<el-table-column label="序号" width="60" type="index" />
<el-table-column prop="order_no" label="订单号" min-width="180" />
<el-table-column label="金额" width="100">
<template #default="{ row }">{{ (row.amount / 100).toFixed(2) }} </template>
</el-table-column>
<el-table-column prop="subject" label="说明" min-width="160" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
{{ row.status === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" width="180">
<template #default="{ row }">{{ row.created_at || row.paid_at || '-' }}</template>
</el-table-column>
</el-table>
<div v-if="renewalList.length === 0" style="text-align:center;color:#999;padding:32px 0">暂无续费记录</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { listAgents, createAgent, updateAgent, listAgentRenewals } from '../../api/agent.js'
import { listRegionCascades } from '../../api/regionPricing.js'
import PaymentDialog from '../../components/PaymentDialog.vue'
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const cascadeOptions = ref([])
const cascadeLoaded = ref(false)
const filters = ref({ keyword: '', phone: '', province: '', region: '', cascaderRegion: [], expiryRange: null })
const pageSizes = ref([20, 50, 100])
async function buildParams() {
const params = { page: page.value, pageSize: pageSize.value }
if (filters.value.keyword) params.keyword = filters.value.keyword
if (filters.value.phone) params.phone = filters.value.phone
if (filters.value.province) params.province = filters.value.province
if (filters.value.region) params.region = filters.value.region
if (filters.value.expiryRange && filters.value.expiryRange.length === 2) {
params.expired_at_from = filters.value.expiryRange[0]
params.expired_at_to = filters.value.expiryRange[1]
}
return params
}
async function fetchData() {
loading.value = true
try { const r = await listAgents(await buildParams()); list.value = r.list; total.value = r.total }
finally { loading.value = false }
}
function onFilterCascaderChange(val) {
if (val && val.length >= 2) {
filters.value.province = val[0]
filters.value.region = val[1]
} else {
filters.value.province = ''
filters.value.region = ''
}
}
function doSearch() { page.value = 1; fetchData() }
function onSizeChange(size) { pageSize.value = size; page.value = 1; fetchData() }
function resetSearch() {
filters.value = { keyword: '', phone: '', province: '', region: '', cascaderRegion: [], expiryRange: null }
page.value = 1
fetchData()
}
async function lazyLoadCascades() {
if (cascadeLoaded.value) return
cascadeLoaded.value = true
try {
const r = await listRegionCascades()
cascadeOptions.value = (r.list || []).map(item => ({
value: item.province,
label: item.province,
children: (item.cities || []).map(city => ({ value: city, label: city }))
}))
} catch (_) {}
}
// create
const showCreate = ref(false); const creating = ref(false); const createFormRef = ref(null)
const createForm = ref({ username: '', password: '', phone: '', name: '', region: '', region_protected: false, cascaderRegion: [] })
const createRules = {
username: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 5, max: 18, message: '账号长度5-18位', trigger: 'blur' },
{ pattern: /^[a-zA-Z][a-zA-Z0-9_]{4,17}$/, message: '账号以字母开头,只能包含字母、数字和下划线', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 18, message: '密码长度6-18位', trigger: 'blur' },
{ pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]).{6,18}$/, message: '密码必须包含大写字母、小写字母、数字和特殊符号', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的11位手机号', trigger: 'blur' }
],
name: [{ required: true, message: '必填', trigger: 'blur' }],
region: [{ required: true, message: '请选择省/市', trigger: 'change' }]
}
function onCascaderChange(val) {
if (val && val.length >= 2) {
createForm.value.province = val[0]
createForm.value.region = val[1]
} else {
createForm.value.province = ''
createForm.value.region = ''
}
}
async function handleCreate() {
const v = await createFormRef.value.validate().catch(() => false)
if (!v) return; creating.value = true
try {
const payload = { ...createForm.value }
delete payload.cascaderRegion
await createAgent(payload)
ElMessage.success('创建成功'); showCreate.value = false; createForm.value = { username: '', password: '', phone: '', name: '', region: '', region_protected: false, cascaderRegion: [] }; await fetchData()
}
finally { creating.value = false }
}
// edit
const showEdit = ref(false); const updating = ref(false); const editFormRef = ref(null)
const editForm = ref({ id: 0, phone: '', name: '', province: '', region: '', cascaderRegion: [] })
const editRules = { name: [{ required: true, message: '必填', trigger: 'blur' }], phone: [{ required: true, message: '必填', trigger: 'blur' }], region: [{ required: true, message: '请选择省/市', trigger: 'change' }] }
function onEditCascaderChange(val) {
if (val && val.length >= 2) {
editForm.value.province = val[0]
editForm.value.region = val[1]
} else {
editForm.value.province = ''
editForm.value.region = ''
}
}
function handleEdit(row) {
lazyLoadCascades()
editForm.value = {
id: row.id,
phone: row.phone || '',
name: row.name,
province: row.province || '',
region: row.region,
cascaderRegion: row.province && row.region ? [row.province, row.region] : []
}
showEdit.value = true
}
async function handleUpdate() {
const v = await editFormRef.value.validate().catch(() => false)
if (!v) return
updating.value = true
try {
const payload = { ...editForm.value }
delete payload.cascaderRegion
await updateAgent(payload)
ElMessage.success('保存成功'); showEdit.value = false; await fetchData()
}
finally { updating.value = false }
}
// renew
const showRenew = ref(false)
const renewAgentInfo = ref(null)
function handleRenew(row) {
renewAgentInfo.value = {
id: row.id,
name: row.name,
expiredAt: row.expired_at || '',
province: row.province || '',
region: row.region || ''
}
showRenew.value = true
}
function onRenewSuccess() {
fetchData()
}
// renewal records
const showRenewals = ref(false)
const renewalList = ref([])
const renewalLoading = ref(false)
async function handleViewRenewals(row) {
renewalLoading.value = true
showRenewals.value = true
renewalList.value = []
try {
const r = await listAgentRenewals(row.id)
renewalList.value = r.list || []
} finally {
renewalLoading.value = false
}
}
onMounted(fetchData)</script>
<style scoped>
.page { padding: 24px 28px; display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; }
.page-body { background: #fff; border-radius: 6px; padding: 16px; border: 1px solid #e5e5e5; display: flex; flex-direction: column; flex: 1; min-height: 0; }
.table-wrapper { flex: 1; min-height: 0; overflow: hidden; }
.btn-primary {
display: inline-flex; align-items: center; gap: 5px; height: 34px; padding: 0 14px;
border-radius: 4px; border: none; background: #000; color: #fff; font-size: 12px; font-weight: 500; cursor: pointer;
}
.btn-primary:hover { background: #333; }
.search-bar { display: flex; gap: 8px; flex-shrink: 0; margin-bottom: 12px; }
.search-spacer { flex: 1; }
.pagination-wrap {
display: flex;
justify-content: center;
padding: 12px 0;
background: #fff;
border-top: 1px solid #e5e5e5;
flex-shrink: 0;
margin: 0 -16px;
padding-left: 16px;
padding-right: 16px;
}
</style>
+151
View File
@@ -0,0 +1,151 @@
<template>
<div class="page">
<div class="page-body">
<div class="search-bar">
<el-input v-model="filters.keyword" placeholder="名称" clearable style="width:150px" />
<el-input v-model="filters.phone" placeholder="手机号" clearable style="width:140px" />
<el-cascader v-model="filters.cascaderRegion" :options="cascadeOptions" style="width:200px" placeholder="所在地区" clearable @change="onFilterCascaderChange" @click="lazyLoadCascades" />
<el-input v-model="filters.agentName" placeholder="所属代理商" clearable style="width:150px" />
<el-button type="primary" @click="doSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<div class="table-wrapper">
<el-table :data="list" border stripe v-loading="loading" height="100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="name" label="名称" />
<el-table-column label="区域" width="150">
<template #default="{ row }">{{ row.province ? row.province + ' - ' : '' }}{{ row.region || '' }}</template>
</el-table-column>
<el-table-column prop="address" label="详细地址" min-width="200" show-overflow-tooltip />
<el-table-column label="余额" width="130">
<template #default="{ row }"><span style="color:#409eff;font-weight:500">{{ (row.balance / 100).toFixed(2) }} </span></template>
</el-table-column>
<el-table-column label="代理商" width="120">
<template #default="{ row }">{{ row.agent_name || row.agent_id || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleTransactions(row)">明细</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="total > 0" class="pagination-wrap">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" :page-sizes="[20, 50, 100]" layout="total, sizes, prev, pager, next" background @current-change="fetchData" @size-change="onSizeChange" />
</div>
</div>
<el-dialog v-model="showTx" title="交易明细" width="700px">
<el-table :data="txList" stripe v-loading="txLoading">
<el-table-column label="类型" width="90">
<template #default="{ row }">
<el-tag :type="row.type === 'recharge' ? 'success' : row.type === 'deduct' ? 'danger' : 'warning'" size="small">
{{ row.type === 'recharge' ? '充值' : row.type === 'deduct' ? '消费' : '退款' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="金额" width="100">
<template #default="{ row }">
<span :style="{ color: row.type === 'recharge' ? '#67c23a' : '#f56c6c' }">{{ row.type === 'recharge' ? '+' : '-' }}{{ (row.amount / 100).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="余额" width="100"><template #default="{ row }">{{ (row.balance_after / 100).toFixed(2) }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="200" />
<el-table-column label="时间" width="180"><template #default="{ row }">{{ row.created_at }}</template></el-table-column>
</el-table>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { listCustomers } from '../../api/customer.js'
import { listTransactions } from '../../api/transaction.js'
import { listRegionCascades } from '../../api/regionPricing.js'
const list = ref([]); const total = ref(0); const page = ref(1); const pageSize = ref(20); const loading = ref(false)
const cascadeOptions = ref([])
const cascadeLoaded = ref(false)
const filters = ref({ keyword: '', phone: '', province: '', region: '', cascaderRegion: [], agentName: '' })
const pageSizes = ref([20, 50, 100])
async function buildParams() {
const params = { agent_id: 0, page: page.value, pageSize: pageSize.value }
if (filters.value.keyword) params.keyword = filters.value.keyword
if (filters.value.phone) params.phone = filters.value.phone
if (filters.value.province) params.province = filters.value.province
if (filters.value.region) params.region = filters.value.region
if (filters.value.agentName) params.agent_name = filters.value.agentName
return params
}
async function fetchData() {
loading.value = true
try { const r = await listCustomers(await buildParams()); list.value = r.list; total.value = r.total }
finally { loading.value = false }
}
function onFilterCascaderChange(val) {
if (val && val.length >= 2) {
filters.value.province = val[0]
filters.value.region = val[1]
} else {
filters.value.province = ''
filters.value.region = ''
}
}
function doSearch() { page.value = 1; fetchData() }
function onSizeChange(size) { pageSize.value = size; page.value = 1; fetchData() }
function resetSearch() {
filters.value = { keyword: '', phone: '', province: '', region: '', cascaderRegion: [], agentName: '' }
page.value = 1
fetchData()
}
async function lazyLoadCascades() {
if (cascadeLoaded.value) return
cascadeLoaded.value = true
try {
const r = await listRegionCascades()
cascadeOptions.value = (r.list || []).map(item => ({
value: item.province,
label: item.province,
children: (item.cities || []).map(city => ({ value: city, label: city }))
}))
} catch (_) {}
}
const showTx = ref(false); const txList = ref([]); const txLoading = ref(false)
async function handleTransactions(row) {
txLoading.value = true; showTx.value = true
try {
const r = await listTransactions(row.id, 1, 20)
txList.value = r.list || []
} finally { txLoading.value = false }
}
onMounted(fetchData)
</script>
<style scoped>
.page { padding: 24px 28px; display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; }
.page-body { background: #fff; border-radius: 6px; padding: 16px; border: 1px solid #e5e5e5; display: flex; flex-direction: column; flex: 1; min-height: 0; }
.table-wrapper { flex: 1; min-height: 0; overflow: hidden; }
.search-bar { display: flex; gap: 8px; flex-shrink: 0; margin-bottom: 12px; flex-wrap: wrap; }
.pagination-wrap {
display: flex;
justify-content: center;
padding: 12px 0;
background: #fff;
border-top: 1px solid #e5e5e5;
flex-shrink: 0;
margin: 0 -16px;
padding-left: 16px;
padding-right: 16px;
}
</style>
+179
View File
@@ -0,0 +1,179 @@
<template>
<div class="page">
<div class="page-body">
<div class="search-bar">
<el-input v-model="filters.keyword" placeholder="名称" clearable style="width:150px" />
<el-input v-model="filters.phone" placeholder="手机号" clearable style="width:140px" />
<el-cascader v-model="filters.cascaderRegion" :options="cascadeOptions" style="width:200px" placeholder="所在地区" clearable @change="onFilterCascaderChange" @click="lazyLoadCascades" />
<el-button type="primary" @click="doSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
<span class="search-spacer"></span>
<button class="btn-primary" @click="showCreate = true; lazyLoadCascades()">新建客户</button>
</div>
<div class="table-wrapper">
<el-table :data="list" border stripe v-loading="loading" height="100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="name" label="名称" />
<el-table-column label="区域" width="150">
<template #default="{ row }">{{ row.province ? row.province + ' - ' : '' }}{{ row.region || '' }}</template>
</el-table-column>
<el-table-column prop="address" label="详细地址" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="total > 0" class="pagination-wrap">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" :page-sizes="[20, 50, 100]" layout="total, sizes, prev, pager, next" background @current-change="fetchData" @size-change="onSizeChange" />
</div>
</div>
<el-dialog v-model="showCreate" title="新建客户" width="500px">
<el-form ref="createFormRef" :model="createForm" :rules="rules" label-width="80px">
<el-form-item label="手机号" prop="phone"><el-input v-model="createForm.phone" /></el-form-item>
<el-form-item label="名称" prop="name"><el-input v-model="createForm.name" /></el-form-item>
<el-form-item label="详细地址" prop="address"><el-input v-model="createForm.address" type="textarea" :rows="2" placeholder="请输入详细地址,系统将自动识别所在地区" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="handleCreate">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="showEdit" title="编辑客户" width="500px">
<el-form ref="editFormRef" :model="editForm" :rules="editRules" label-width="80px">
<el-form-item label="手机号" prop="phone"><el-input v-model="editForm.phone" /></el-form-item>
<el-form-item label="名称" prop="name"><el-input v-model="editForm.name" /></el-form-item>
<el-form-item label="详细地址"><el-input v-model="editForm.address" type="textarea" :rows="2" placeholder="修改地址将重新识别所在地区" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" :loading="updating" @click="handleUpdate">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { listCustomers, createCustomer, updateCustomer } from '../../api/customer.js'
import { listRegionCascades } from '../../api/regionPricing.js'
import { useAuthStore } from '../../stores/auth.js'
const auth = useAuthStore()
const list = ref([]); const total = ref(0); const page = ref(1); const pageSize = ref(20); const loading = ref(false)
const cascadeOptions = ref([])
const cascadeLoaded = ref(false)
const filters = ref({ keyword: '', phone: '', province: '', region: '', cascaderRegion: [] })
const pageSizes = ref([20, 50, 100])
async function buildParams() {
const params = { agent_id: auth.user.id, page: page.value, pageSize: pageSize.value }
if (filters.value.keyword) params.keyword = filters.value.keyword
if (filters.value.phone) params.phone = filters.value.phone
if (filters.value.province) params.province = filters.value.province
if (filters.value.region) params.region = filters.value.region
return params
}
async function fetchData() {
loading.value = true
try {
const r = await listCustomers(await buildParams())
list.value = r.list; total.value = r.total
} finally { loading.value = false }
}
function onFilterCascaderChange(val) {
if (val && val.length >= 2) {
filters.value.province = val[0]
filters.value.region = val[1]
} else {
filters.value.province = ''
filters.value.region = ''
}
}
function doSearch() { page.value = 1; fetchData() }
function onSizeChange(size) { pageSize.value = size; page.value = 1; fetchData() }
function resetSearch() {
filters.value = { keyword: '', phone: '', province: '', region: '', cascaderRegion: [] }
page.value = 1
fetchData()
}
async function lazyLoadCascades() {
if (cascadeLoaded.value) return
cascadeLoaded.value = true
try {
const r = await listRegionCascades()
cascadeOptions.value = (r.list || []).map(item => ({
value: item.province,
label: item.province,
children: (item.cities || []).map(city => ({ value: city, label: city }))
}))
} catch (_) {}
}
const showCreate = ref(false); const creating = ref(false); const createFormRef = ref(null)
const createForm = ref({ phone: '', name: '', address: '' })
const rules = { phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }, { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的11位手机号', trigger: 'blur' }], name: [{ required: true, message: '必填', trigger: 'blur' }], address: [{ required: true, message: '请输入详细地址', trigger: 'blur' }] }
async function handleCreate() {
const v = await createFormRef.value.validate().catch(() => false)
if (!v) return; creating.value = true
try {
await createCustomer({ ...createForm.value, agent_id: auth.user.id })
ElMessage.success('创建成功')
showCreate.value = false
createForm.value = { phone: '', name: '', address: '' }
await fetchData()
} finally { creating.value = false }
}
const showEdit = ref(false); const updating = ref(false); const editFormRef = ref(null)
const editForm = ref({ id: 0, phone: '', name: '', address: '' })
const editRules = { phone: [{ required: true, message: '必填', trigger: 'blur' }], name: [{ required: true, message: '必填', trigger: 'blur' }] }
function handleEdit(row) { editForm.value = { id: row.id, phone: row.phone, name: row.name, address: row.address || '' }; showEdit.value = true }
async function handleUpdate() {
const v = await editFormRef.value.validate().catch(() => false)
if (!v) return; updating.value = true
try {
await updateCustomer(editForm.value)
ElMessage.success('保存成功')
showEdit.value = false
await fetchData()
} finally { updating.value = false }
}
onMounted(fetchData)
</script>
<style scoped>
.page { padding: 24px 28px; display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; }
.page-body { background: #fff; border-radius: 6px; padding: 16px; border: 1px solid #e5e5e5; display: flex; flex-direction: column; flex: 1; min-height: 0; }
.table-wrapper { flex: 1; min-height: 0; overflow: hidden; }
.btn-primary {
display: inline-flex; align-items: center; gap: 5px; height: 34px; padding: 0 14px;
border-radius: 4px; border: none; background: #000; color: #fff; font-size: 12px; font-weight: 500; cursor: pointer;
}
.btn-primary:hover { background: #333; }
.search-bar { display: flex; gap: 8px; flex-shrink: 0; margin-bottom: 12px; }
.search-spacer { flex: 1; }
.pagination-wrap {
display: flex;
justify-content: center;
padding: 12px 0;
background: #fff;
border-top: 1px solid #e5e5e5;
flex-shrink: 0;
margin: 0 -16px;
padding-left: 16px;
padding-right: 16px;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<template>
<div class="page">
<div class="page-body">
<div class="balance-card">
<p class="balance-label">当前余额</p>
<p class="balance-amount">{{ (balance / 100).toFixed(2) }} <span class="balance-unit"></span></p>
<el-button type="primary" size="small" style="margin-top:12px" @click="showPayment = true">充值</el-button>
</div>
</div>
<PaymentDialog v-model="showPayment" @success="onPaymentSuccess" />
<div class="page-body" style="margin-top:16px">
<h3 style="font-size:14px;font-weight:500;margin:0 0 12px;color:#333">最近交易</h3>
<el-table :data="txList" stripe v-loading="txLoading">
<el-table-column label="类型" width="90">
<template #default="{ row }">
<el-tag :type="row.type === 'recharge' ? 'success' : row.type === 'deduct' ? 'danger' : 'warning'" size="small">
{{ row.type === 'recharge' ? '充值' : row.type === 'deduct' ? '消费' : '退款' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="金额" width="120">
<template #default="{ row }">
<span :style="{ color: row.type === 'recharge' ? '#67c23a' : '#f56c6c' }">{{ row.type === 'recharge' ? '+' : '-' }}{{ (row.amount / 100).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="余额" width="120"><template #default="{ row }">{{ (row.balance_after / 100).toFixed(2) }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="200" />
<el-table-column label="时间" width="180"><template #default="{ row }">{{ row.created_at }}</template></el-table-column>
</el-table>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth.js'
import { listTransactions } from '../../api/transaction.js'
import PaymentDialog from '../../components/PaymentDialog.vue'
const auth = useAuthStore()
const balance = ref(auth.user?.balance || 0)
const txList = ref([]); const txLoading = ref(false)
const showPayment = ref(false)
function onPaymentSuccess() {
// 刷新余额(从后端重新获取当前用户信息)
showPayment.value = false
fetchBalance()
// 刷新交易列表
fetchTx()
}
async function fetchBalance() {
try {
const r = await listTransactions(0, 1, 1)
if (r.list && r.list.length > 0) {
balance.value = r.list[0].balance_after
if (auth.user) {
auth.user.balance = balance.value
localStorage.setItem('user', JSON.stringify(auth.user))
}
}
} catch (_) {}
}
async function fetchTx() {
txLoading.value = true
try { const r = await listTransactions(0, 1, 10); txList.value = r.list || [] }
finally { txLoading.value = false }
}
onMounted(fetchTx)
</script>
<style scoped>
.page { padding: 24px 28px; }
.page-body { background: #fff; border-radius: 6px; padding: 20px; border: 1px solid #e5e5e5; }
.balance-card { text-align: center; padding: 20px; }
.balance-label { font-size: 13px; color: #999; margin: 0 0 8px; }
.balance-amount { font-size: 40px; font-weight: 700; color: #409eff; margin: 0; }
.balance-unit { font-size: 16px; font-weight: 400; color: #999; }
</style>
@@ -0,0 +1,50 @@
<template>
<div class="page">
<div class="page-header"><h2>交易记录</h2></div>
<div class="page-body">
<el-table :data="list" border stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column label="类型" width="90">
<template #default="{ row }">
<el-tag :type="row.type === 'recharge' ? 'success' : row.type === 'deduct' ? 'danger' : 'warning'" size="small">
{{ row.type === 'recharge' ? '充值' : row.type === 'deduct' ? '消费' : '退款' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="金额" width="120">
<template #default="{ row }">
<span :style="{ color: row.type === 'recharge' ? '#67c23a' : '#f56c6c' }">{{ row.type === 'recharge' ? '+' : '-' }}{{ (row.amount / 100).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="变动前" width="120"><template #default="{ row }">{{ (row.balance_before / 100).toFixed(2) }}</template></el-table-column>
<el-table-column label="变动后" width="120"><template #default="{ row }">{{ (row.balance_after / 100).toFixed(2) }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="200" />
<el-table-column prop="created_by" label="操作人" width="120" />
<el-table-column label="时间" width="180"><template #default="{ row }">{{ row.created_at }}</template></el-table-column>
</el-table>
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next,total" style="margin-top:16px;justify-content:flex-end" background @current-change="fetchData" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { listTransactions } from '../../api/transaction.js'
const list = ref([]); const total = ref(0); const page = ref(1); const pageSize = ref(20); const loading = ref(false)
async function fetchData() {
loading.value = true
try { const r = await listTransactions(0, page.value, pageSize.value); list.value = r.list; total.value = r.total }
finally { loading.value = false }
}
onMounted(fetchData)
</script>
<style scoped>
.page { padding: 24px 28px; }
.page-header { margin-bottom: 16px; }
.page-header h2 { margin: 0; font-size: 18px; font-weight: 600; color: #111; }
.page-body { background: #fff; border-radius: 6px; padding: 16px; border: 1px solid #e5e5e5; }
</style>
+36
View File
@@ -0,0 +1,36 @@
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const target = env.VITE_API_PROXY_TARGET || 'http://localhost:3006'
// 浏览器导航页面请求(需要 SPA)vs API 调用(需要代理到后端)
const spaBypass = (req) => {
const accept = req.headers['accept'] || ''
if (accept.includes('text/html')) {
return '/index.html'
}
}
const proxyRules = ['/drama', '/agent', '/customer', '/payment', '/transaction', '/region', '/user', '/scene', '/character', '/prop', '/bgm', '/episode', '/generation', '/model-config', '/user-model-config']
const proxy = {}
for (const rule of proxyRules) {
proxy[rule] = { target, changeOrigin: true, bypass: spaBypass }
}
proxy['/workspace'] = { target, changeOrigin: true }
return {
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5173,
proxy
}
}
})