1
This commit is contained in:
@@ -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
|
||||
Binary file not shown.
-485
@@ -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 + Vite(video-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、一个 Controller(17×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`(运行时最终配置)
|
||||
@@ -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 |
|
||||
@@ -0,0 +1,6 @@
|
||||
# 默认登录账号密码(开发/生产通用)
|
||||
#VITE_DEFAULT_USERNAME=customer1
|
||||
#VITE_DEFAULT_PASSWORD=Tongli686^*^
|
||||
|
||||
# Vite 开发服务器代理目标(仅本地 dev 模式使用,生产由 nginx 代理)
|
||||
VITE_API_PROXY_TARGET=http://localhost:3006
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.cache
|
||||
|
||||
# 本地环境变量覆盖(每个开发者可自定义)
|
||||
.env.local
|
||||
.env.*.local
|
||||
.gstack/
|
||||
@@ -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>
|
||||
Generated
+2986
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import request from './request.js'
|
||||
|
||||
export function login(data) {
|
||||
return request.post('/user/login', data)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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": "澳门特别行政区" }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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: 'Native(PC扫码)' }
|
||||
],
|
||||
alipay: [
|
||||
{ value: 'jsapi', label: 'JSAPI(支付宝小程序)' },
|
||||
{ value: 'h5', label: 'H5(手机网页)' },
|
||||
{ value: 'app', label: 'APP(移动应用)' },
|
||||
{ value: 'precreate', label: 'Precreate(PC扫码)' }
|
||||
]
|
||||
}
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user