Просмотр исходного кода

init: video script admin frontend

yumeng 2 дней назад
Сommit
5ee45f1f08

+ 3 - 0
.env.development

@@ -0,0 +1,3 @@
+VITE_PORT=5173
+VITE_PROXY_TARGET=http://localhost:9011
+VITE_API_BASE_URL=/api

+ 3 - 0
.env.production

@@ -0,0 +1,3 @@
+VITE_PORT=5173
+VITE_PROXY_TARGET=http://prod-api.example.com
+VITE_API_BASE_URL=http://prod-api.example.com/api

+ 3 - 0
.env.test

@@ -0,0 +1,3 @@
+VITE_PORT=5174
+VITE_PROXY_TARGET=http://test-api.example.com
+VITE_API_BASE_URL=http://test-api.example.com/api

+ 26 - 0
.gitignore

@@ -0,0 +1,26 @@
+# dependencies
+node_modules/
+
+# build output
+dist/
+dist-ssr/
+
+# logs
+logs/
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# editor / IDE
+.idea/
+.vscode/
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# system
+.DS_Store

+ 27 - 0
README.md

@@ -0,0 +1,27 @@
+# video-script-admin-frontend
+
+Vue 3 + Vite + Element Plus 管理台,包含:
+
+- 登录
+- 仪表盘
+- 用户管理
+- 角色管理
+- 部门管理
+- 菜单管理
+- 视频脚本任务管理
+
+## 启动
+
+```bash
+npm install
+npm run dev
+```
+
+## 环境
+
+- `npm run dev`:开发环境,接口默认代理到 `http://localhost:8081`
+- `npm run dev:test`:测试环境联调,接口默认代理到 `http://test-api.example.com`
+- `npm run build`:生产构建
+- `npm run build:test`:测试环境构建
+
+可通过根目录下的 `.env.development`、`.env.test`、`.env.production` 分别调整端口、代理地址和接口前缀。

+ 12 - 0
index.html

@@ -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 Script Admin</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

Разница между файлами не показана из-за своего большого размера
+ 1861 - 0
package-lock.json


+ 24 - 0
package.json

@@ -0,0 +1,24 @@
+{
+  "name": "video-script-admin-frontend",
+  "version": "1.0.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite --mode development",
+    "dev:test": "vite --mode test",
+    "build": "vite build --mode production",
+    "build:test": "vite build --mode test",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "axios": "^1.7.2",
+    "element-plus": "^2.8.0",
+    "pinia": "^2.1.7",
+    "vue": "^3.4.31",
+    "vue-router": "^4.4.0"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.1.2",
+    "vite": "^5.3.4"
+  }
+}

+ 3 - 0
src/App.vue

@@ -0,0 +1,3 @@
+<template>
+  <router-view />
+</template>

+ 36 - 0
src/api/http.js

@@ -0,0 +1,36 @@
+import axios from "axios";
+import { ElMessage } from "element-plus";
+
+const http = axios.create({
+  baseURL: import.meta.env.VITE_API_BASE_URL || "/api",
+  timeout: 15000,
+});
+
+http.interceptors.request.use((config) => {
+  const token = localStorage.getItem("VIDEO_SCRIPT_TOKEN");
+  if (token) {
+    config.headers.Authorization = `Bearer ${token}`;
+  }
+  return config;
+});
+
+http.interceptors.response.use(
+  (response) => {
+    const payload = response.data;
+    if (payload.code !== 200) {
+      ElMessage.error(payload.message || "请求失败");
+      return Promise.reject(new Error(payload.message || "请求失败"));
+    }
+    return payload.data;
+  },
+  (error) => {
+    if (error.response?.status === 401) {
+      localStorage.removeItem("VIDEO_SCRIPT_TOKEN");
+      window.location.href = "/login";
+    }
+    ElMessage.error(error.message || "网络异常");
+    return Promise.reject(error);
+  }
+);
+
+export default http;

+ 25 - 0
src/api/modules.js

@@ -0,0 +1,25 @@
+import http from "./http";
+
+export const loginApi = (data) => http.post("/auth/login", data);
+export const profileApi = () => http.get("/auth/profile");
+
+export const listUsersApi = (keyword) => http.get("/users", { params: { keyword } });
+export const saveUserApi = (data) => http.post("/users", data);
+export const deleteUserApi = (id) => http.delete(`/users/${id}`);
+
+export const listRolesApi = (keyword) => http.get("/roles", { params: { keyword } });
+export const saveRoleApi = (data) => http.post("/roles", data);
+export const deleteRoleApi = (id) => http.delete(`/roles/${id}`);
+
+export const listDeptsApi = (keyword) => http.get("/depts", { params: { keyword } });
+export const saveDeptApi = (data) => http.post("/depts", data);
+export const deleteDeptApi = (id) => http.delete(`/depts/${id}`);
+
+export const listMenusApi = (keyword) => http.get("/menus", { params: { keyword } });
+export const saveMenuApi = (data) => http.post("/menus", data);
+export const deleteMenuApi = (id) => http.delete(`/menus/${id}`);
+
+export const listTasksApi = (params) => http.get("/tasks", { params });
+export const getTaskApi = (id) => http.get(`/tasks/${id}`);
+export const saveTaskApi = (data) => http.post("/tasks", data);
+export const deleteTaskApi = (id) => http.delete(`/tasks/${id}`);

+ 205 - 0
src/layout/AppLayout.vue

@@ -0,0 +1,205 @@
+<template>
+  <div class="layout-shell">
+    <el-container class="layout-frame">
+      <el-aside width="220px" class="layout-aside">
+        <div class="layout-brand">Video Script Admin</div>
+        <el-menu
+          class="layout-menu"
+          router
+          background-color="#0f172a"
+          text-color="#94a3b8"
+          active-text-color="#f8fafc"
+          :default-active="$route.path"
+        >
+          <el-menu-item v-for="item in menus" :key="item.id" :index="item.routePath">
+            <span>{{ item.menuName }}</span>
+          </el-menu-item>
+        </el-menu>
+      </el-aside>
+      <el-container class="layout-main-shell">
+        <el-header class="layout-header">
+          <div class="layout-header__title">{{ $route.meta.title || "管理台" }}</div>
+          <el-dropdown trigger="click" placement="bottom-end">
+            <button class="account-trigger" type="button">
+              <el-avatar :size="42" class="account-trigger__avatar">
+                {{ userInitial }}
+              </el-avatar>
+              <div class="account-trigger__copy">
+                <span class="account-trigger__name">{{ authStore.userInfo?.nickname || "未登录" }}</span>
+                <span class="account-trigger__meta">{{ authStore.userInfo?.username || "-" }}</span>
+              </div>
+            </button>
+            <template #dropdown>
+              <el-dropdown-menu class="account-menu">
+                <div class="account-menu__profile">
+                  <el-avatar :size="44" class="account-menu__avatar">{{ userInitial }}</el-avatar>
+                  <div class="account-menu__text">
+                    <div class="account-menu__name">{{ authStore.userInfo?.nickname || "-" }}</div>
+                    <div class="account-menu__meta">用户名:{{ authStore.userInfo?.username || "-" }}</div>
+                  </div>
+                </div>
+                <el-dropdown-item divided @click="logout">退出登录</el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+        </el-header>
+        <el-main class="layout-main">
+          <router-view />
+        </el-main>
+      </el-container>
+    </el-container>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted } from "vue";
+import { useRouter } from "vue-router";
+import { useAuthStore } from "../stores/auth";
+
+const router = useRouter();
+const authStore = useAuthStore();
+
+const menus = computed(() => authStore.menus);
+const userInitial = computed(() => {
+  const nickname = authStore.userInfo?.nickname;
+  const username = authStore.userInfo?.username;
+  return String(nickname || username || "U").slice(0, 1).toUpperCase();
+});
+
+onMounted(async () => {
+  if (!authStore.userInfo) {
+    await authStore.loadProfile();
+  }
+});
+
+const logout = () => {
+  authStore.logout();
+  router.push("/login");
+};
+</script>
+
+<style scoped>
+.layout-frame {
+  min-height: 100vh;
+}
+
+.layout-aside {
+  background: #0f172a;
+  color: #fff;
+  border-right: 1px solid rgba(148, 163, 184, 0.08);
+}
+
+.layout-brand {
+  padding: 24px 20px 18px;
+  font-size: 18px;
+  font-weight: 700;
+  color: #f8fafc;
+}
+
+.layout-menu {
+  border-right: 0;
+}
+
+.layout-main-shell {
+  background: transparent;
+}
+
+.layout-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 16px;
+  height: 76px;
+  padding: 0 24px;
+  background: rgba(255, 255, 255, 0.72);
+  backdrop-filter: blur(12px);
+  border-bottom: 1px solid rgba(148, 163, 184, 0.16);
+}
+
+.layout-header__title {
+  font-size: 22px;
+  font-weight: 700;
+  color: #0f172a;
+}
+
+.layout-main {
+  padding: 20px 24px 24px;
+}
+
+.account-trigger {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 6px 8px 6px 6px;
+  border: 1px solid rgba(148, 163, 184, 0.28);
+  border-radius: 999px;
+  background: rgba(255, 255, 255, 0.88);
+  box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
+  cursor: pointer;
+}
+
+.account-trigger__avatar,
+.account-menu__avatar {
+  background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
+  color: #fff;
+  font-weight: 700;
+}
+
+.account-trigger__copy {
+  display: grid;
+  text-align: left;
+  padding-right: 8px;
+}
+
+.account-trigger__name {
+  color: #0f172a;
+  font-size: 14px;
+  font-weight: 700;
+  line-height: 1.2;
+}
+
+.account-trigger__meta {
+  color: #64748b;
+  font-size: 12px;
+  line-height: 1.2;
+}
+
+.account-menu__profile {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 8px 12px 12px;
+}
+
+.account-menu__text {
+  display: grid;
+  gap: 4px;
+}
+
+.account-menu__name {
+  color: #0f172a;
+  font-size: 14px;
+  font-weight: 700;
+}
+
+.account-menu__meta {
+  color: #64748b;
+  font-size: 12px;
+}
+
+@media (max-width: 768px) {
+  .account-trigger__copy {
+    display: none;
+  }
+
+  .layout-header__title {
+    font-size: 20px;
+  }
+
+  .layout-header,
+  .layout-main {
+    padding-left: 16px;
+    padding-right: 16px;
+  }
+}
+</style>

+ 9 - 0
src/main.js

@@ -0,0 +1,9 @@
+import { createApp } from "vue";
+import { createPinia } from "pinia";
+import ElementPlus from "element-plus";
+import "element-plus/dist/index.css";
+import App from "./App.vue";
+import router from "./router";
+import "./styles.css";
+
+createApp(App).use(createPinia()).use(router).use(ElementPlus).mount("#app");

+ 47 - 0
src/router/index.js

@@ -0,0 +1,47 @@
+import { createRouter, createWebHistory } from "vue-router";
+import LoginView from "../views/LoginView.vue";
+import AppLayout from "../layout/AppLayout.vue";
+import DashboardView from "../views/DashboardView.vue";
+import UserView from "../views/UserView.vue";
+import RoleView from "../views/RoleView.vue";
+import DeptView from "../views/DeptView.vue";
+import MenuView from "../views/MenuView.vue";
+import TaskView from "../views/TaskView.vue";
+import TaskDetailView from "../views/TaskDetailView.vue";
+import TaskFormView from "../views/TaskFormView.vue";
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes: [
+    { path: "/login", component: LoginView },
+    {
+      path: "/",
+      component: AppLayout,
+      redirect: "/dashboard",
+      children: [
+        { path: "dashboard", component: DashboardView, meta: { title: "仪表盘" } },
+        { path: "users", component: UserView, meta: { title: "用户管理" } },
+        { path: "roles", component: RoleView, meta: { title: "角色管理" } },
+        { path: "depts", component: DeptView, meta: { title: "部门管理" } },
+        { path: "menus", component: MenuView, meta: { title: "菜单管理" } },
+        { path: "tasks", component: TaskView, meta: { title: "脚本任务" } },
+        { path: "tasks/create", component: TaskFormView, meta: { title: "新增任务" } },
+        { path: "tasks/:id", component: TaskDetailView, meta: { title: "任务详情" } },
+        { path: "tasks/:id/edit", component: TaskFormView, meta: { title: "编辑任务" } },
+      ],
+    },
+  ],
+});
+
+router.beforeEach((to) => {
+  const token = localStorage.getItem("VIDEO_SCRIPT_TOKEN");
+  if (to.path !== "/login" && !token) {
+    return "/login";
+  }
+  if (to.path === "/login" && token) {
+    return "/dashboard";
+  }
+  return true;
+});
+
+export default router;

+ 30 - 0
src/stores/auth.js

@@ -0,0 +1,30 @@
+import { defineStore } from "pinia";
+import { loginApi, profileApi } from "../api/modules";
+
+export const useAuthStore = defineStore("auth", {
+  state: () => ({
+    token: localStorage.getItem("VIDEO_SCRIPT_TOKEN") || "",
+    userInfo: null,
+    menus: [],
+  }),
+  actions: {
+    async login(form) {
+      const payload = await loginApi(form);
+      this.token = payload.token;
+      this.userInfo = payload.userInfo;
+      this.menus = payload.menus || [];
+      localStorage.setItem("VIDEO_SCRIPT_TOKEN", payload.token);
+    },
+    async loadProfile() {
+      const payload = await profileApi();
+      this.userInfo = payload.userInfo;
+      this.menus = payload.menus || [];
+    },
+    logout() {
+      this.token = "";
+      this.userInfo = null;
+      this.menus = [];
+      localStorage.removeItem("VIDEO_SCRIPT_TOKEN");
+    },
+  },
+});

+ 43 - 0
src/styles.css

@@ -0,0 +1,43 @@
+:root {
+  color: #1f2937;
+  background: #eef3f8;
+  font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+}
+
+a {
+  color: inherit;
+  text-decoration: none;
+}
+
+.page-card {
+  background: #fff;
+  border-radius: 18px;
+  padding: 20px;
+  box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
+}
+
+.toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.toolbar-left {
+  display: flex;
+  gap: 12px;
+}
+
+.layout-shell {
+  min-height: 100vh;
+  background: linear-gradient(135deg, #dde8f3 0%, #eef3f8 35%, #f8fafc 100%);
+}

+ 30 - 0
src/views/DashboardView.vue

@@ -0,0 +1,30 @@
+<template>
+  <div class="page-card">
+    <el-row :gutter="16">
+      <el-col :span="8">
+        <div style="padding: 24px; border-radius: 18px; background: linear-gradient(135deg, #dbeafe, #eff6ff)">
+          <div style="color: #475569">当前用户</div>
+          <div style="margin-top: 10px; font-size: 28px; font-weight: 700">{{ authStore.userInfo?.nickname }}</div>
+        </div>
+      </el-col>
+      <el-col :span="8">
+        <div style="padding: 24px; border-radius: 18px; background: linear-gradient(135deg, #dcfce7, #f0fdf4)">
+          <div style="color: #475569">可见菜单数</div>
+          <div style="margin-top: 10px; font-size: 28px; font-weight: 700">{{ authStore.menus.length }}</div>
+        </div>
+      </el-col>
+      <el-col :span="8">
+        <div style="padding: 24px; border-radius: 18px; background: linear-gradient(135deg, #fee2e2, #fff1f2)">
+          <div style="color: #475569">默认能力</div>
+          <div style="margin-top: 10px; font-size: 20px; font-weight: 700">视频脚本任务管理</div>
+        </div>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup>
+import { useAuthStore } from "../stores/auth";
+
+const authStore = useAuthStore();
+</script>

+ 7 - 0
src/views/DeptView.vue

@@ -0,0 +1,7 @@
+<template>
+  <CrudDeptView />
+</template>
+
+<script setup>
+import CrudDeptView from "./modules/CrudDeptView.vue";
+</script>

+ 55 - 0
src/views/LoginView.vue

@@ -0,0 +1,55 @@
+<template>
+  <div
+    style="min-height: 100vh; display: flex; align-items: center; justify-content: center; background: radial-gradient(circle at top, #dbeafe, #0f172a)"
+  >
+    <div style="display: grid; grid-template-columns: 1.2fr 420px; width: 1080px; max-width: 92vw; overflow: hidden; border-radius: 28px; background: #fff">
+      <div style="padding: 56px; background: linear-gradient(160deg, #0f172a, #1d4ed8); color: #fff">
+        <div style="font-size: 42px; font-weight: 800; line-height: 1.15">视频脚本<br />管理平台</div>
+        <p style="margin-top: 18px; color: rgba(255,255,255,0.8); line-height: 1.8">
+          用于管理登录、组织架构、菜单权限,以及视频链接拆解任务。
+        </p>
+        <div style="margin-top: 36px; padding: 18px; border-radius: 18px; background: rgba(255,255,255,0.08)">
+          <div>默认账号:admin / 123456</div>
+          <div style="margin-top: 8px">默认账号:operator / 123456</div>
+        </div>
+      </div>
+      <div style="padding: 56px 42px">
+        <div style="font-size: 28px; font-weight: 700; margin-bottom: 24px">登录</div>
+        <el-form :model="form" @submit.prevent>
+          <el-form-item>
+            <el-input v-model="form.username" placeholder="用户名" size="large" />
+          </el-form-item>
+          <el-form-item>
+            <el-input v-model="form.password" placeholder="密码" show-password size="large" />
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" size="large" style="width: 100%" @click="handleLogin">进入系统</el-button>
+          </el-form-item>
+        </el-form>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { reactive } from "vue";
+import { ElMessage } from "element-plus";
+import { useRouter } from "vue-router";
+import { useAuthStore } from "../stores/auth";
+
+const router = useRouter();
+const authStore = useAuthStore();
+const form = reactive({
+  username: "admin",
+  password: "123456",
+});
+
+const handleLogin = async () => {
+  if (!form.username || !form.password) {
+    ElMessage.warning("请输入用户名和密码");
+    return;
+  }
+  await authStore.login(form);
+  router.push("/dashboard");
+};
+</script>

+ 7 - 0
src/views/MenuView.vue

@@ -0,0 +1,7 @@
+<template>
+  <CrudMenuView />
+</template>
+
+<script setup>
+import CrudMenuView from "./modules/CrudMenuView.vue";
+</script>

+ 7 - 0
src/views/RoleView.vue

@@ -0,0 +1,7 @@
+<template>
+  <CrudRoleView />
+</template>
+
+<script setup>
+import CrudRoleView from "./modules/CrudRoleView.vue";
+</script>

+ 484 - 0
src/views/TaskDetailView.vue

@@ -0,0 +1,484 @@
+<template>
+  <div class="detail-page">
+    <div class="detail-topbar">
+      <el-button text class="detail-topbar__back" @click="goBack">返回列表</el-button>
+    </div>
+
+    <section class="hero-card">
+      <div class="hero-main">
+        <div class="hero-kicker">Task Detail</div>
+        <h1 class="hero-title">{{ task.taskName || "任务详情" }}</h1>
+      </div>
+      <div class="hero-actions">
+        <el-button type="primary" @click="reload">刷新数据</el-button>
+      </div>
+    </section>
+
+    <section class="stats-grid">
+      <article class="stat-card stat-card--status">
+        <div class="stat-label">任务状态</div>
+        <div class="stat-status">
+          <el-tag :type="statusTagType(task.status)" effect="dark" size="large">{{ formatStatus(task.status) }}</el-tag>
+        </div>
+        <div class="stat-desc">{{ statusDescription }}</div>
+      </article>
+
+      <article class="stat-card">
+        <div class="stat-label">任务编号</div>
+        <div class="stat-value">#{{ task.id || "-" }}</div>
+      </article>
+
+      <article class="stat-card">
+        <div class="stat-label">脚本类型</div>
+        <div class="stat-value">{{ formatScriptType(task.scriptType) }}</div>
+      </article>
+
+      <article class="stat-card">
+        <div class="stat-label">视频数量</div>
+        <div class="stat-value">{{ videoUrls.length || 0 }}</div>
+      </article>
+
+      <article class="stat-card">
+        <div class="stat-label">创建人</div>
+        <div class="stat-value">{{ task.creatorName || "-" }}</div>
+      </article>
+
+      <article class="stat-card">
+        <div class="stat-label">CodeDesk任务ID</div>
+        <div class="stat-value stat-value--small">{{ task.codedeskTaskId || "-" }}</div>
+      </article>
+    </section>
+
+    <section class="section-card">
+      <div class="section-header">
+        <div>
+          <div class="section-kicker">Summary</div>
+          <h2 class="section-title">摘要</h2>
+        </div>
+      </div>
+      <pre class="content-block content-block--summary">{{ task.summary || "-" }}</pre>
+    </section>
+
+    <section class="section-card">
+      <div class="section-header">
+        <div>
+          <div class="section-kicker">Analysis</div>
+          <h2 class="section-title">视频分析文案</h2>
+        </div>
+      </div>
+      <pre class="content-block content-block--analysis">{{ task.analysisContent || "-" }}</pre>
+    </section>
+
+    <section class="section-card">
+      <div class="section-header">
+        <div>
+          <div class="section-kicker">Brief</div>
+          <h2 class="section-title">用户要求</h2>
+        </div>
+      </div>
+      <pre class="content-block content-block--brief">{{ task.promptHint || "-" }}</pre>
+    </section>
+
+    <section class="section-card section-card--script">
+      <div class="section-header">
+        <div>
+          <div class="section-kicker">Final Script</div>
+          <h2 class="section-title">脚本内容</h2>
+        </div>
+      </div>
+      <pre class="content-block content-block--script">{{ task.scriptContent || "-" }}</pre>
+    </section>
+
+    <section class="section-card" v-if="task.errorMessage">
+      <div class="section-header">
+        <div>
+          <div class="section-kicker">Error</div>
+          <h2 class="section-title">失败原因</h2>
+        </div>
+      </div>
+      <pre class="content-block content-block--danger">{{ task.errorMessage }}</pre>
+    </section>
+
+    <section class="section-card">
+      <div class="section-header">
+        <div>
+          <div class="section-kicker">Inputs</div>
+          <h2 class="section-title">素材输入</h2>
+        </div>
+      </div>
+      <el-tabs class="material-tabs">
+        <el-tab-pane label="结构化内容">
+          <pre class="content-block content-block--material">{{ task.videoContent || "-" }}</pre>
+        </el-tab-pane>
+        <el-tab-pane label="转写文本">
+          <pre class="content-block content-block--material">{{ task.transcript || "-" }}</pre>
+        </el-tab-pane>
+      </el-tabs>
+    </section>
+
+    <section class="section-card">
+      <div class="section-header">
+        <div>
+          <div class="section-kicker">Source URLs</div>
+          <h2 class="section-title">视频链接</h2>
+        </div>
+      </div>
+      <div class="link-list">
+        <div class="link-item link-item--lead">
+          <div class="link-label">首个链接</div>
+          <div class="link-text">{{ task.videoUrl || "-" }}</div>
+        </div>
+        <div v-for="(item, index) in videoUrls" :key="index" class="link-item">
+          <div class="link-label">视频 {{ index + 1 }}</div>
+          <div class="link-text">{{ item }}</div>
+        </div>
+        <div v-if="!videoUrls.length" class="empty-tip">暂无视频链接</div>
+      </div>
+    </section>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, reactive } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import { ElMessage } from "element-plus";
+import { getTaskApi } from "../api/modules";
+
+const route = useRoute();
+const router = useRouter();
+const task = reactive({});
+
+const videoUrls = computed(() => {
+  if (!task.videoUrls) {
+    return task.videoUrl ? [task.videoUrl] : [];
+  }
+  return String(task.videoUrls)
+    .split(/\r?\n|,/)
+    .map((item) => item.trim())
+    .filter(Boolean);
+});
+
+const statusDescription = computed(() => {
+  if (task.status === "DONE") return "结果已生成,可直接审阅与修改。";
+  if (task.status === "RUNNING") return "任务处理中,建议稍后刷新。";
+  if (task.status === "FAILED") return "任务执行失败,建议优先排查错误原因。";
+  return "等待进入处理队列。";
+});
+
+const loadData = async () => {
+  const id = route.params.id;
+  const payload = await getTaskApi(id);
+  Object.assign(task, payload);
+};
+
+const reload = async () => {
+  try {
+    await loadData();
+  } catch (error) {
+    ElMessage.error(error.message || "加载任务详情失败");
+  }
+};
+
+const goBack = () => {
+  router.push("/tasks");
+};
+
+const statusTagType = (status) => {
+  if (status === "DONE") return "success";
+  if (status === "RUNNING") return "warning";
+  if (status === "FAILED") return "danger";
+  return "info";
+};
+
+const formatStatus = (value) => {
+  const map = {
+    PENDING: "待处理",
+    RUNNING: "处理中",
+    DONE: "已完成",
+    FAILED: "失败",
+  };
+  return map[value] || value || "-";
+};
+
+const formatScriptType = (value) => {
+  const map = {
+    VOICEOVER: "口播稿",
+    STORYBOARD: "分镜稿",
+    SUMMARY: "摘要稿",
+  };
+  return map[value] || value || "-";
+};
+
+onMounted(reload);
+</script>
+
+<style scoped>
+.detail-page {
+  display: grid;
+  gap: 18px;
+}
+
+.detail-topbar {
+  display: flex;
+  align-items: center;
+}
+
+.detail-topbar__back {
+  padding-left: 0;
+  font-size: 14px;
+  font-weight: 600;
+}
+
+.hero-card {
+  display: flex;
+  justify-content: space-between;
+  gap: 20px;
+  padding: 24px 28px;
+  border-radius: 20px;
+  background: linear-gradient(135deg, #17324d 0%, #234869 100%);
+  color: #f8fafc;
+  box-shadow: 0 18px 36px rgba(15, 23, 42, 0.12);
+}
+
+.hero-main {
+  max-width: 760px;
+}
+
+.hero-kicker {
+  font-size: 12px;
+  font-weight: 800;
+  letter-spacing: 0.12em;
+  text-transform: uppercase;
+  color: rgba(255, 255, 255, 0.68);
+}
+
+.hero-title {
+  margin: 10px 0;
+  font-size: 36px;
+  line-height: 1.08;
+  font-weight: 800;
+}
+
+.hero-actions {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  gap: 12px;
+  min-width: 132px;
+}
+
+.stats-grid {
+  display: grid;
+  grid-template-columns: 1.8fr repeat(5, minmax(0, 1fr));
+  gap: 14px;
+}
+
+.stat-card,
+.section-card {
+  background: #fff;
+  border: 1px solid rgba(226, 232, 240, 0.92);
+  border-radius: 18px;
+  box-shadow: 0 12px 26px rgba(15, 23, 42, 0.05);
+}
+
+.stat-card {
+  padding: 18px;
+}
+
+.stat-card--status {
+  background: linear-gradient(135deg, #fff9dd 0%, #fffef3 100%);
+}
+
+.stat-label {
+  color: #64748b;
+  font-size: 12px;
+  font-weight: 700;
+  letter-spacing: 0.04em;
+  text-transform: uppercase;
+}
+
+.stat-status {
+  margin-top: 14px;
+}
+
+.stat-desc {
+  margin-top: 12px;
+  color: #4b5563;
+  line-height: 1.7;
+}
+
+.stat-value {
+  margin-top: 14px;
+  color: #0f172a;
+  font-size: 24px;
+  line-height: 1.15;
+  font-weight: 800;
+  word-break: break-word;
+}
+
+.stat-value--small {
+  font-size: 15px;
+}
+
+.section-card {
+  padding: 20px;
+}
+
+.section-card--script {
+  background: linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
+}
+
+.section-header {
+  margin-bottom: 14px;
+}
+
+.section-kicker {
+  color: #64748b;
+  font-size: 11px;
+  font-weight: 800;
+  letter-spacing: 0.12em;
+  text-transform: uppercase;
+}
+
+.section-title {
+  margin: 8px 0 6px;
+  color: #0f172a;
+  font-size: 24px;
+  line-height: 1.15;
+  font-weight: 800;
+}
+
+.content-block {
+  margin: 0;
+  min-height: 140px;
+  padding: 16px 18px;
+  border-radius: 14px;
+  background: #f8fafc;
+  white-space: pre-wrap;
+  word-break: break-word;
+  line-height: 1.8;
+  color: #0f172a;
+  overflow-x: auto;
+}
+
+.content-block--summary {
+  background: linear-gradient(135deg, #fff8e8 0%, #fffdf5 100%);
+}
+
+.content-block--analysis {
+  background: linear-gradient(135deg, #eefcf4 0%, #f8fffb 100%);
+}
+
+.content-block--brief {
+  background: linear-gradient(135deg, #fffdf2 0%, #fffaf0 100%);
+}
+
+.content-block--script {
+  min-height: 340px;
+  background: linear-gradient(180deg, #f6f8ff 0%, #ffffff 100%);
+  font-size: 15px;
+}
+
+.content-block--danger {
+  background: linear-gradient(135deg, #fff1f2 0%, #fff8f8 100%);
+  color: #991b1b;
+}
+
+.content-block--material {
+  min-height: 260px;
+}
+
+.link-list {
+  display: grid;
+  gap: 10px;
+}
+
+.link-item {
+  padding: 14px 16px;
+  border-radius: 14px;
+  background: #f8fafc;
+}
+
+.link-item--lead {
+  background: linear-gradient(135deg, #eef6ff 0%, #f8fbff 100%);
+}
+
+.link-label {
+  color: #0369a1;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.link-text {
+  margin-top: 8px;
+  line-height: 1.75;
+  word-break: break-all;
+  color: #0f172a;
+}
+
+.empty-tip {
+  padding: 16px;
+  border-radius: 14px;
+  background: #f8fafc;
+  color: #94a3b8;
+  text-align: center;
+}
+
+:deep(.material-tabs .el-tabs__nav-wrap::after) {
+  background-color: #e2e8f0;
+}
+
+:deep(.material-tabs .el-tabs__item) {
+  font-weight: 700;
+}
+
+:deep(.material-tabs .el-tabs__item.is-active) {
+  color: #2563eb;
+}
+
+:deep(.material-tabs .el-tabs__active-bar) {
+  background-color: #2563eb;
+}
+
+@media (max-width: 1440px) {
+  .stats-grid {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 960px) {
+  .hero-card {
+    flex-direction: column;
+  }
+
+  .hero-actions {
+    flex-direction: row;
+    min-width: 0;
+  }
+
+  .stats-grid {
+    grid-template-columns: 1fr;
+  }
+}
+
+@media (max-width: 640px) {
+  .hero-card,
+  .stat-card,
+  .section-card {
+    padding: 16px;
+    border-radius: 16px;
+  }
+
+  .hero-title {
+    font-size: 30px;
+  }
+
+  .section-title {
+    font-size: 21px;
+  }
+
+  .content-block {
+    padding: 14px;
+  }
+}
+</style>

+ 229 - 0
src/views/TaskFormView.vue

@@ -0,0 +1,229 @@
+<template>
+  <div class="task-form-shell">
+    <div class="task-form-topbar">
+      <el-button text class="task-form-topbar__back" @click="goBack">
+        返回列表
+      </el-button>
+    </div>
+
+    <section class="page-card task-form-page">
+      <div class="task-form-header">
+        <div>
+          <h1 class="task-form-title">{{ isEdit ? "编辑任务" : "新增任务" }}</h1>
+        </div>
+      </div>
+
+      <el-form :model="form" label-width="96px" class="task-form-body">
+        <el-form-item label="任务名称">
+          <el-input v-model="form.taskName" placeholder="请输入任务名称"/>
+        </el-form-item>
+        <el-form-item label="脚本类型">
+          <el-select v-model="form.scriptType" style="width: 100%">
+            <el-option value="VOICEOVER" label="口播稿"/>
+            <el-option value="STORYBOARD" label="分镜稿"/>
+            <el-option value="SUMMARY" label="摘要稿"/>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="视频链接">
+          <el-input
+              v-model="videoUrlsText"
+              type="textarea"
+              :rows="6"
+              placeholder="支持同时提交多个视频链接,每行一个"
+          />
+        </el-form-item>
+
+        <el-form-item label="用户要求">
+          <el-input
+              v-model="form.promptHint"
+              type="textarea"
+              :rows="7"
+              placeholder="建议写清:产出类型、核心卖点、风格语气、结构要求、禁用词或限制。"
+          />
+          <div class="prompt-example">
+            <div class="prompt-example__title">提示词示例</div>
+            <div class="prompt-example__text">
+              请基于这些视频素材,输出一版适合抖音/快手投放的口播脚本。要求开头 3 秒直接抛出痛点,
+              中间突出产品核心卖点和真实使用场景,结尾加入明确行动引导。整体语气要自然、有说服力,
+              不要太像硬广,控制在 60 秒以内。
+            </div>
+          </div>
+        </el-form-item>
+
+
+        <el-form-item v-if="isEdit" label="状态">
+          <el-select v-model="form.status" style="width: 100%">
+            <el-option value="PENDING" label="待处理"/>
+            <el-option value="RUNNING" label="处理中"/>
+            <el-option value="DONE" label="已完成"/>
+            <el-option value="FAILED" label="失败"/>
+          </el-select>
+        </el-form-item>
+
+        <el-form-item v-if="isEdit" label="脚本内容">
+          <el-input v-model="form.scriptContent" type="textarea" :rows="8" placeholder="可选,编辑时手动修订脚本"/>
+        </el-form-item>
+
+        <el-form-item v-if="isEdit" label="摘要">
+          <el-input v-model="form.summary" type="textarea" :rows="5" placeholder="可选,编辑时手动修订摘要"/>
+        </el-form-item>
+      </el-form>
+
+      <div class="task-form-footer">
+        <el-button size="large" @click="goBack">返回列表</el-button>
+        <el-button type="primary" size="large" @click="handleSave">保存任务</el-button>
+      </div>
+    </section>
+  </div>
+</template>
+
+<script setup>
+import {computed, onMounted, reactive, ref} from "vue";
+import {useRoute, useRouter} from "vue-router";
+import {ElMessage} from "element-plus";
+import {getTaskApi, saveTaskApi} from "../api/modules";
+
+const route = useRoute();
+const router = useRouter();
+const isEdit = computed(() => Boolean(route.params.id));
+const videoUrlsText = ref("");
+const form = reactive({
+  id: null,
+  taskName: "",
+  videoUrl: "",
+  codedeskTaskId: "",
+  promptHint: "",
+  videoContent: "",
+  analysisContent: "",
+  scriptType: "VOICEOVER",
+  status: "PENDING",
+  transcript: "",
+  scriptContent: "",
+  summary: "",
+});
+
+const parseVideoUrls = () =>
+    videoUrlsText.value
+        .split(/\r?\n|,/)
+        .map((item) => item.trim())
+        .filter(Boolean);
+
+const loadDetail = async () => {
+  if (!isEdit.value) {
+    return;
+  }
+  const payload = await getTaskApi(route.params.id);
+  Object.assign(form, payload);
+  videoUrlsText.value = payload.videoUrls || payload.videoUrl || "";
+};
+
+const handleSave = async () => {
+  const videoUrls = parseVideoUrls();
+  if (!form.taskName?.trim()) {
+    ElMessage.warning("请输入任务名称");
+    return;
+  }
+  if (!videoUrls.length) {
+    ElMessage.warning("请至少输入一个视频链接");
+    return;
+  }
+  await saveTaskApi({
+    ...form,
+    taskName: form.taskName?.trim(),
+    codedeskTaskId: form.codedeskTaskId?.trim(),
+    promptHint: form.promptHint?.trim(),
+    analysisContent: form.analysisContent?.trim(),
+    videoUrl: videoUrls[0],
+    videoUrls,
+  });
+  ElMessage.success(isEdit.value ? "任务已更新" : "任务已创建");
+  router.push("/tasks");
+};
+
+const goBack = () => {
+  router.push("/tasks");
+};
+
+onMounted(loadDetail);
+</script>
+
+<style scoped>
+.task-form-shell {
+  display: grid;
+  gap: 14px;
+}
+
+.task-form-topbar {
+  display: flex;
+  align-items: center;
+}
+
+.task-form-topbar__back {
+  padding-left: 0;
+  font-size: 14px;
+  font-weight: 600;
+}
+
+.task-form-page {
+  display: grid;
+  gap: 20px;
+}
+
+.task-form-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 16px;
+}
+
+.task-form-title {
+  margin: 0;
+  font-size: 28px;
+  line-height: 1.15;
+  font-weight: 700;
+  color: #0f172a;
+}
+
+.task-form-body {
+  max-width: 1120px;
+}
+
+.prompt-example {
+  margin-top: 12px;
+  padding: 14px 16px;
+  border-radius: 12px;
+  background: #f8fafc;
+  border: 1px solid #e2e8f0;
+}
+
+.prompt-example__title {
+  margin-bottom: 8px;
+  color: #0f172a;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.prompt-example__text {
+  color: #475569;
+  line-height: 1.7;
+  font-size: 13px;
+}
+
+.task-form-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  padding-top: 8px;
+  border-top: 1px solid #eef2f7;
+}
+
+@media (max-width: 900px) {
+  .task-form-footer {
+    flex-direction: column-reverse;
+  }
+
+  .task-form-footer :deep(.el-button) {
+    width: 100%;
+  }
+}
+</style>

+ 7 - 0
src/views/TaskView.vue

@@ -0,0 +1,7 @@
+<template>
+  <CrudTaskView />
+</template>
+
+<script setup>
+import CrudTaskView from "./modules/CrudTaskView.vue";
+</script>

+ 7 - 0
src/views/UserView.vue

@@ -0,0 +1,7 @@
+<template>
+  <CrudUserView />
+</template>
+
+<script setup>
+import CrudUserView from "./modules/CrudUserView.vue";
+</script>

+ 62 - 0
src/views/modules/CrudDeptView.vue

@@ -0,0 +1,62 @@
+<template>
+  <div class="page-card">
+    <div class="toolbar">
+      <div class="toolbar-left">
+        <el-input v-model="keyword" placeholder="搜索部门" style="width: 240px" />
+        <el-button @click="loadData">搜索</el-button>
+      </div>
+      <el-button type="primary" @click="openDialog()">新增部门</el-button>
+    </div>
+    <el-table :data="tableData" border>
+      <el-table-column prop="deptName" label="部门名称" />
+      <el-table-column prop="parentId" label="父级ID" />
+      <el-table-column prop="leader" label="负责人" />
+      <el-table-column prop="phone" label="联系电话" />
+      <el-table-column prop="sort" label="排序" />
+      <el-table-column label="操作" width="180">
+        <template #default="{ row }">
+          <el-button link type="primary" @click="openDialog(row)">编辑</el-button>
+          <el-button link type="danger" @click="handleDelete(row.id)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <el-dialog v-model="visible" title="部门信息" width="520px">
+      <el-form :model="form" label-width="88px">
+        <el-form-item label="部门名称"><el-input v-model="form.deptName" /></el-form-item>
+        <el-form-item label="父级ID"><el-input v-model="form.parentId" /></el-form-item>
+        <el-form-item label="负责人"><el-input v-model="form.leader" /></el-form-item>
+        <el-form-item label="联系电话"><el-input v-model="form.phone" /></el-form-item>
+        <el-form-item label="排序"><el-input v-model="form.sort" /></el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="form.status" style="width: 100%">
+            <el-option :value="1" label="启用" />
+            <el-option :value="0" label="停用" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="visible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, reactive, ref } from "vue";
+import { ElMessageBox } from "element-plus";
+import { deleteDeptApi, listDeptsApi, saveDeptApi } from "../../api/modules";
+
+const keyword = ref("");
+const tableData = ref([]);
+const visible = ref(false);
+const form = reactive({ id: null, deptName: "", parentId: 0, leader: "", phone: "", sort: 0, status: 1 });
+
+const resetForm = () => Object.assign(form, { id: null, deptName: "", parentId: 0, leader: "", phone: "", sort: 0, status: 1 });
+const loadData = async () => { tableData.value = await listDeptsApi(keyword.value); };
+const openDialog = (row) => { resetForm(); if (row) Object.assign(form, row); visible.value = true; };
+const handleSave = async () => { await saveDeptApi({ ...form, parentId: Number(form.parentId), sort: Number(form.sort) }); visible.value = false; await loadData(); };
+const handleDelete = async (id) => { await ElMessageBox.confirm("确认删除该部门?", "提示"); await deleteDeptApi(id); await loadData(); };
+
+onMounted(loadData);
+</script>

+ 63 - 0
src/views/modules/CrudMenuView.vue

@@ -0,0 +1,63 @@
+<template>
+  <div class="page-card">
+    <div class="toolbar">
+      <div class="toolbar-left">
+        <el-input v-model="keyword" placeholder="搜索菜单" style="width: 240px" />
+        <el-button @click="loadData">搜索</el-button>
+      </div>
+      <el-button type="primary" @click="openDialog()">新增菜单</el-button>
+    </div>
+    <el-table :data="tableData" border>
+      <el-table-column prop="menuName" label="菜单名称" />
+      <el-table-column prop="routePath" label="路由" />
+      <el-table-column prop="component" label="组件" />
+      <el-table-column prop="permissionCode" label="权限标识" />
+      <el-table-column label="操作" width="180">
+        <template #default="{ row }">
+          <el-button link type="primary" @click="openDialog(row)">编辑</el-button>
+          <el-button link type="danger" @click="handleDelete(row.id)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <el-dialog v-model="visible" title="菜单信息" width="560px">
+      <el-form :model="form" label-width="88px">
+        <el-form-item label="菜单名称"><el-input v-model="form.menuName" /></el-form-item>
+        <el-form-item label="父级ID"><el-input v-model="form.parentId" /></el-form-item>
+        <el-form-item label="路由"><el-input v-model="form.routePath" /></el-form-item>
+        <el-form-item label="组件"><el-input v-model="form.component" /></el-form-item>
+        <el-form-item label="图标"><el-input v-model="form.icon" /></el-form-item>
+        <el-form-item label="排序"><el-input v-model="form.sort" /></el-form-item>
+        <el-form-item label="是否可见">
+          <el-select v-model="form.visible" style="width: 100%">
+            <el-option :value="1" label="显示" />
+            <el-option :value="0" label="隐藏" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="权限标识"><el-input v-model="form.permissionCode" /></el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="visible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, reactive, ref } from "vue";
+import { ElMessageBox } from "element-plus";
+import { deleteMenuApi, listMenusApi, saveMenuApi } from "../../api/modules";
+
+const keyword = ref("");
+const tableData = ref([]);
+const visible = ref(false);
+const form = reactive({ id: null, menuName: "", parentId: 0, routePath: "", component: "", icon: "", sort: 0, visible: 1, permissionCode: "" });
+
+const resetForm = () => Object.assign(form, { id: null, menuName: "", parentId: 0, routePath: "", component: "", icon: "", sort: 0, visible: 1, permissionCode: "" });
+const loadData = async () => { tableData.value = await listMenusApi(keyword.value); };
+const openDialog = (row) => { resetForm(); if (row) Object.assign(form, row); visible.value = true; };
+const handleSave = async () => { await saveMenuApi({ ...form, parentId: Number(form.parentId), sort: Number(form.sort) }); visible.value = false; await loadData(); };
+const handleDelete = async (id) => { await ElMessageBox.confirm("确认删除该菜单?", "提示"); await deleteMenuApi(id); await loadData(); };
+
+onMounted(loadData);
+</script>

+ 59 - 0
src/views/modules/CrudRoleView.vue

@@ -0,0 +1,59 @@
+<template>
+  <div class="page-card">
+    <div class="toolbar">
+      <div class="toolbar-left">
+        <el-input v-model="keyword" placeholder="搜索角色" style="width: 240px" />
+        <el-button @click="loadData">搜索</el-button>
+      </div>
+      <el-button type="primary" @click="openDialog()">新增角色</el-button>
+    </div>
+    <el-table :data="tableData" border>
+      <el-table-column prop="roleCode" label="角色编码" />
+      <el-table-column prop="roleName" label="角色名称" />
+      <el-table-column prop="remark" label="备注" />
+      <el-table-column prop="status" label="状态" />
+      <el-table-column label="操作" width="180">
+        <template #default="{ row }">
+          <el-button link type="primary" @click="openDialog(row)">编辑</el-button>
+          <el-button link type="danger" @click="handleDelete(row.id)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <el-dialog v-model="visible" title="角色信息" width="520px">
+      <el-form :model="form" label-width="88px">
+        <el-form-item label="角色编码"><el-input v-model="form.roleCode" /></el-form-item>
+        <el-form-item label="角色名称"><el-input v-model="form.roleName" /></el-form-item>
+        <el-form-item label="备注"><el-input v-model="form.remark" /></el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="form.status" style="width: 100%">
+            <el-option :value="1" label="启用" />
+            <el-option :value="0" label="停用" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="visible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, reactive, ref } from "vue";
+import { ElMessageBox } from "element-plus";
+import { deleteRoleApi, listRolesApi, saveRoleApi } from "../../api/modules";
+
+const keyword = ref("");
+const tableData = ref([]);
+const visible = ref(false);
+const form = reactive({ id: null, roleCode: "", roleName: "", remark: "", status: 1 });
+
+const resetForm = () => Object.assign(form, { id: null, roleCode: "", roleName: "", remark: "", status: 1 });
+const loadData = async () => { tableData.value = await listRolesApi(keyword.value); };
+const openDialog = (row) => { resetForm(); if (row) Object.assign(form, row); visible.value = true; };
+const handleSave = async () => { await saveRoleApi({ ...form }); visible.value = false; await loadData(); };
+const handleDelete = async (id) => { await ElMessageBox.confirm("确认删除该角色?", "提示"); await deleteRoleApi(id); await loadData(); };
+
+onMounted(loadData);
+</script>

+ 453 - 0
src/views/modules/CrudTaskView.vue

@@ -0,0 +1,453 @@
+<template>
+  <div class="task-page">
+    <section class="task-page__top">
+      <section class="overview-strip">
+        <div class="overview-item">
+          <span class="overview-item__label">全部任务</span>
+          <strong class="overview-item__value">{{ totalCount }}</strong>
+        </div>
+        <div class="overview-item">
+          <span class="overview-item__label">待处理</span>
+          <strong class="overview-item__value">{{ statusCount("PENDING") }}</strong>
+        </div>
+        <div class="overview-item">
+          <span class="overview-item__label">处理中</span>
+          <strong class="overview-item__value">{{ statusCount("RUNNING") }}</strong>
+        </div>
+        <div class="overview-item">
+          <span class="overview-item__label">已完成</span>
+          <strong class="overview-item__value">{{ statusCount("DONE") }}</strong>
+        </div>
+        <div class="overview-item overview-item--danger">
+          <span class="overview-item__label">失败</span>
+          <strong class="overview-item__value">{{ statusCount("FAILED") }}</strong>
+        </div>
+      </section>
+      <div class="task-page__actions">
+        <el-button type="primary" size="large" @click="openCreatePage">新增任务</el-button>
+      </div>
+    </section>
+
+    <section class="page-card workspace-card">
+      <div class="workspace-toolbar">
+        <div class="workspace-toolbar__left">
+          <el-input
+            v-model="keyword"
+            placeholder="搜索任务名称、用户要求或视频链接"
+            clearable
+            class="filter-input"
+            @keyup.enter="handleSearch"
+            @clear="handleSearch"
+          />
+          <el-select
+            v-model="statusFilter"
+            placeholder="全部状态"
+            clearable
+            class="filter-select"
+            @change="handleSearch"
+            @clear="handleSearch"
+          >
+            <el-option label="待处理" value="PENDING" />
+            <el-option label="处理中" value="RUNNING" />
+            <el-option label="已完成" value="DONE" />
+            <el-option label="失败" value="FAILED" />
+          </el-select>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+        </div>
+        <div class="workspace-toolbar__right">默认排序:创建时间从新到旧</div>
+      </div>
+
+      <el-table :data="pagedData" class="task-table" empty-text="暂无任务数据">
+        <el-table-column label="任务名称" min-width="220">
+          <template #default="{ row }">
+            <div class="task-title-cell">
+              <div class="task-title-cell__title">{{ row.taskName || "-" }}</div>
+              <div class="task-title-cell__sub">
+                <span>#{{ row.id }}</span>
+                <span>{{ formatScriptType(row.scriptType) }}</span>
+              </div>
+            </div>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="任务发起人" width="150">
+          <template #default="{ row }">
+            <div class="creator-cell">
+              <el-avatar :size="30" class="creator-cell__avatar">
+                {{ creatorInitial(row.creatorName) }}
+              </el-avatar>
+              <span>{{ row.creatorName || "-" }}</span>
+            </div>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="视频数量" width="100" align="center">
+          <template #default="{ row }">
+            <span class="count-pill">{{ formatVideoCount(row.videoUrls, row.videoUrl) }}</span>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="用户要求" min-width="220" show-overflow-tooltip>
+          <template #default="{ row }">
+            {{ summarizeText(row.promptHint, 48) }}
+          </template>
+        </el-table-column>
+
+        <el-table-column label="摘要" min-width="240" show-overflow-tooltip>
+          <template #default="{ row }">
+            {{ summarizeText(row.summary || row.analysisContent, 52) }}
+          </template>
+        </el-table-column>
+
+        <el-table-column label="状态" width="110" align="center">
+          <template #default="{ row }">
+            <el-tag :type="statusTagType(row.status)" effect="light" round>
+              {{ formatStatus(row.status) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+
+        <el-table-column prop="createTime" label="创建时间" width="180" />
+
+        <el-table-column label="操作" width="180" fixed="right" align="center">
+          <template #default="{ row }">
+            <div class="action-links">
+              <el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
+              <el-button link type="primary" @click="openEditPage(row.id)">编辑</el-button>
+              <el-button link type="danger" @click="handleDelete(row.id)">删除</el-button>
+            </div>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="workspace-footer">
+        <span class="workspace-footer__meta">共 {{ tableData.length }} 条任务</span>
+        <el-pagination
+          background
+          layout="total, sizes, prev, pager, next, jumper"
+          :total="tableData.length"
+          :page-sizes="[10, 20, 50]"
+          v-model:current-page="currentPage"
+          v-model:page-size="pageSize"
+        />
+      </div>
+    </section>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import { useRouter } from "vue-router";
+import { ElMessage, ElMessageBox } from "element-plus";
+import { deleteTaskApi, listTasksApi } from "../../api/modules";
+
+const router = useRouter();
+const keyword = ref("");
+const statusFilter = ref("");
+const tableData = ref([]);
+const currentPage = ref(1);
+const pageSize = ref(10);
+
+const totalCount = computed(() => tableData.value.length);
+
+const pagedData = computed(() => {
+  const start = (currentPage.value - 1) * pageSize.value;
+  return tableData.value.slice(start, start + pageSize.value);
+});
+
+const loadData = async () => {
+  try {
+    const result = await listTasksApi({
+      keyword: keyword.value?.trim() || undefined,
+      status: statusFilter.value || undefined,
+    });
+    tableData.value = Array.isArray(result) ? result : [];
+  } catch (error) {
+    ElMessage.error(error.message || "加载任务列表失败");
+  }
+};
+
+const handleSearch = async () => {
+  currentPage.value = 1;
+  await loadData();
+};
+
+const handleReset = async () => {
+  keyword.value = "";
+  statusFilter.value = "";
+  currentPage.value = 1;
+  await loadData();
+};
+
+const openCreatePage = () => {
+  router.push("/tasks/create");
+};
+
+const openEditPage = (id) => {
+  router.push(`/tasks/${id}/edit`);
+};
+
+const openDetail = (id) => {
+  router.push(`/tasks/${id}`);
+};
+
+const handleDelete = async (id) => {
+  try {
+    await ElMessageBox.confirm("确认删除该任务?", "提示", { type: "warning" });
+    await deleteTaskApi(id);
+    ElMessage.success("任务已删除");
+    await loadData();
+  } catch (error) {
+    if (error !== "cancel") {
+      ElMessage.error(error.message || "删除任务失败");
+    }
+  }
+};
+
+const formatVideoCount = (videoUrls, videoUrl) => {
+  if (!videoUrls) {
+    return videoUrl ? "1条" : "0条";
+  }
+  const count = String(videoUrls)
+    .split(/\r?\n|,/)
+    .map((item) => item.trim())
+    .filter(Boolean).length;
+  return `${count || 0}条`;
+};
+
+const formatStatus = (value) => {
+  const map = {
+    PENDING: "待处理",
+    RUNNING: "处理中",
+    DONE: "已完成",
+    FAILED: "失败",
+  };
+  return map[value] || value || "-";
+};
+
+const statusTagType = (value) => {
+  if (value === "DONE") return "success";
+  if (value === "RUNNING") return "warning";
+  if (value === "FAILED") return "danger";
+  return "info";
+};
+
+const formatScriptType = (value) => {
+  const map = {
+    VOICEOVER: "口播稿",
+    STORYBOARD: "分镜稿",
+    SUMMARY: "摘要稿",
+  };
+  return map[value] || value || "-";
+};
+
+const summarizeText = (value, limit = 48) => {
+  if (!value) {
+    return "-";
+  }
+  const normalized = String(value).replace(/\s+/g, " ").trim();
+  return normalized.length > limit ? `${normalized.slice(0, limit)}...` : normalized;
+};
+
+const creatorInitial = (value) => String(value || "U").slice(0, 1).toUpperCase();
+const statusCount = (status) => tableData.value.filter((item) => item.status === status).length;
+
+onMounted(loadData);
+</script>
+
+<style scoped>
+.task-page {
+  display: grid;
+  gap: 18px;
+}
+
+.task-page__top {
+  display: flex;
+  align-items: stretch;
+  justify-content: space-between;
+  gap: 16px;
+  padding-top: 4px;
+}
+
+.task-page__actions {
+  display: flex;
+  align-items: flex-end;
+}
+
+.overview-strip {
+  display: grid;
+  flex: 1;
+  grid-template-columns: repeat(5, minmax(0, 1fr));
+  gap: 12px;
+}
+
+.overview-item {
+  padding: 18px 20px;
+  border-radius: 16px;
+  background: rgba(255, 255, 255, 0.82);
+  border: 1px solid rgba(226, 232, 240, 0.9);
+  box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
+}
+
+.overview-item__label {
+  display: block;
+  color: #64748b;
+  font-size: 13px;
+  margin-bottom: 10px;
+}
+
+.overview-item__value {
+  color: #0f172a;
+  font-size: 30px;
+  line-height: 1;
+  font-weight: 700;
+}
+
+.overview-item--danger .overview-item__value {
+  color: #b42318;
+}
+
+.workspace-card {
+  padding: 0;
+  overflow: hidden;
+}
+
+.workspace-toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 16px;
+  padding: 18px 20px;
+  border-bottom: 1px solid #eef2f7;
+}
+
+.workspace-toolbar__left {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+}
+
+.workspace-toolbar__right {
+  color: #94a3b8;
+  font-size: 13px;
+  white-space: nowrap;
+}
+
+.filter-input {
+  width: 360px;
+}
+
+.filter-select {
+  width: 150px;
+}
+
+.task-table {
+  width: 100%;
+}
+
+.task-title-cell {
+  display: grid;
+  gap: 6px;
+}
+
+.task-title-cell__title {
+  color: #0f172a;
+  font-size: 15px;
+  font-weight: 600;
+}
+
+.task-title-cell__sub {
+  display: flex;
+  gap: 12px;
+  flex-wrap: wrap;
+  color: #64748b;
+  font-size: 12px;
+}
+
+.creator-cell {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: #334155;
+}
+
+.creator-cell__avatar {
+  background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
+  color: #fff;
+  font-weight: 700;
+}
+
+.count-pill {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 52px;
+  padding: 4px 10px;
+  border-radius: 999px;
+  background: #eff6ff;
+  color: #1d4ed8;
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.action-links {
+  display: flex;
+  justify-content: center;
+  gap: 6px;
+  white-space: nowrap;
+}
+
+.workspace-footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 18px 20px 20px;
+  border-top: 1px solid #eef2f7;
+}
+
+.workspace-footer__meta {
+  color: #64748b;
+  font-size: 13px;
+}
+
+@media (max-width: 1360px) {
+  .overview-strip {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 900px) {
+  .task-page__top,
+  .workspace-toolbar,
+  .workspace-footer {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .task-page__actions {
+    align-items: stretch;
+  }
+
+  .overview-strip {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .filter-input,
+  .filter-select {
+    width: 100%;
+  }
+
+  .workspace-toolbar__right {
+    white-space: normal;
+  }
+}
+
+@media (max-width: 640px) {
+  .overview-strip {
+    grid-template-columns: 1fr;
+  }
+}
+</style>

+ 107 - 0
src/views/modules/CrudUserView.vue

@@ -0,0 +1,107 @@
+<template>
+  <div class="page-card">
+    <div class="toolbar">
+      <div class="toolbar-left">
+        <el-input v-model="keyword" placeholder="搜索用户名/昵称" style="width: 240px" />
+        <el-button @click="loadData">搜索</el-button>
+      </div>
+      <el-button type="primary" @click="openDialog()">新增用户</el-button>
+    </div>
+    <el-table :data="tableData" border>
+      <el-table-column prop="username" label="用户名" />
+      <el-table-column prop="nickname" label="昵称" />
+      <el-table-column prop="deptName" label="部门" />
+      <el-table-column prop="roleName" label="角色" />
+      <el-table-column prop="phone" label="手机号" />
+      <el-table-column prop="status" label="状态" />
+      <el-table-column label="操作" width="180">
+        <template #default="{ row }">
+          <el-button link type="primary" @click="openDialog(row)">编辑</el-button>
+          <el-button link type="danger" @click="handleDelete(row.id)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <el-dialog v-model="visible" title="用户信息" width="560px">
+      <el-form :model="form" label-width="88px">
+        <el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
+        <el-form-item label="密码"><el-input v-model="form.password" placeholder="不填则默认 123456" /></el-form-item>
+        <el-form-item label="昵称"><el-input v-model="form.nickname" /></el-form-item>
+        <el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
+        <el-form-item label="部门">
+          <el-select v-model="form.deptId" placeholder="请选择部门" style="width: 100%">
+            <el-option
+              v-for="dept in deptOptions"
+              :key="dept.id"
+              :label="dept.deptName"
+              :value="dept.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="角色">
+          <el-select v-model="form.roleId" placeholder="请选择角色" style="width: 100%">
+            <el-option
+              v-for="role in roleOptions"
+              :key="role.id"
+              :label="role.roleName"
+              :value="role.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="form.status" style="width: 100%">
+            <el-option :value="1" label="启用" />
+            <el-option :value="0" label="停用" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="visible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, reactive, ref } from "vue";
+import { ElMessageBox } from "element-plus";
+import { deleteUserApi, listDeptsApi, listRolesApi, listUsersApi, saveUserApi } from "../../api/modules";
+
+const keyword = ref("");
+const tableData = ref([]);
+const visible = ref(false);
+const deptOptions = ref([]);
+const roleOptions = ref([]);
+const form = reactive({ id: null, username: "", password: "", nickname: "", phone: "", deptId: 1, roleId: 1, status: 1 });
+
+const resetForm = () => Object.assign(form, { id: null, username: "", password: "", nickname: "", phone: "", deptId: null, roleId: null, status: 1 });
+const loadData = async () => { tableData.value = await listUsersApi(keyword.value); };
+const loadOptions = async () => {
+  const [depts, roles] = await Promise.all([listDeptsApi(""), listRolesApi("")]);
+  deptOptions.value = Array.isArray(depts) ? depts : [];
+  roleOptions.value = Array.isArray(roles) ? roles : [];
+};
+const applyDefaultSelections = () => {
+  if (!form.deptId && deptOptions.value.length) {
+    form.deptId = deptOptions.value[0].id;
+  }
+  if (!form.roleId && roleOptions.value.length) {
+    form.roleId = roleOptions.value[0].id;
+  }
+};
+const openDialog = async (row) => {
+  resetForm();
+  await loadOptions();
+  if (row) {
+    Object.assign(form, row, { password: "" });
+  }
+  applyDefaultSelections();
+  visible.value = true;
+};
+const handleSave = async () => { await saveUserApi({ ...form, deptId: Number(form.deptId), roleId: Number(form.roleId) }); visible.value = false; await loadData(); };
+const handleDelete = async (id) => { await ElMessageBox.confirm("确认删除该用户?", "提示"); await deleteUserApi(id); await loadData(); };
+
+onMounted(async () => {
+  await Promise.all([loadData(), loadOptions()]);
+});
+</script>

+ 19 - 0
vite.config.js

@@ -0,0 +1,19 @@
+import { defineConfig, loadEnv } from "vite";
+import vue from "@vitejs/plugin-vue";
+
+export default defineConfig(({ mode }) => {
+  const env = loadEnv(mode, process.cwd(), "");
+
+  return {
+    plugins: [vue()],
+    server: {
+      port: Number(env.VITE_PORT || 5173),
+      proxy: {
+        "/api": {
+          target: env.VITE_PROXY_TARGET,
+          changeOrigin: true,
+        },
+      },
+    },
+  };
+});