diff --git a/apps/web/src/pages/my-templates/MyTemplates.tsx b/apps/web/src/pages/my-templates/MyTemplates.tsx
index cf60fafea..f1119f5ff 100644
--- a/apps/web/src/pages/my-templates/MyTemplates.tsx
+++ b/apps/web/src/pages/my-templates/MyTemplates.tsx
@@ -3,126 +3,39 @@
* 卡片视图展示用户已保存的剪辑模板
* 支持搜索、分类筛选、编辑/复制/删除/使用模板生成
*/
-import React, { useState } from "react"
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
-import {
- Typography,
- Card,
- Input,
- Select,
- Tag,
- Button,
- Space,
- Empty,
- Spin,
- Tooltip,
- message,
- Popconfirm,
- Row,
- Col,
-} from "antd"
-import {
- SearchOutlined,
- EditOutlined,
- CopyOutlined,
- DeleteOutlined,
- VideoCameraOutlined,
- AppstoreOutlined,
- PlusOutlined,
-} from "@ant-design/icons"
+import React from "react"
+import { Typography, Input, Select, Button, Empty, Spin, Row, Col } from "antd"
+import { SearchOutlined, AppstoreOutlined, PlusOutlined } from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
-import {
- getEditingTemplates,
- getTemplateCategories,
- deleteEditingTemplate,
- createEditingTemplate,
- MODE_LABELS,
- MODE_COLORS,
- type EditingTemplate,
- type TemplateMode,
-} from "@/api/editing-planner"
+import type { EditingTemplate } from "@/api/editing-planner"
+import { useMyTemplates } from "./hooks/useMyTemplates"
+import { TemplateCard } from "./components/TemplateCard"
import "./MyTemplates.css"
const { Title, Text } = Typography
const MyTemplates: React.FC = () => {
const navigate = useNavigate()
- const queryClient = useQueryClient()
+ const {
+ searchText,
+ setSearchText,
+ filterCategory,
+ setFilterCategory,
+ templates,
+ categories,
+ isLoading,
+ handleCopy,
+ handleDelete,
+ } = useMyTemplates()
- const [searchText, setSearchText] = useState("")
- const [filterCategory, setFilterCategory] = useState("")
-
- /* ── 数据查询 ── */
- const { data: templates = [], isLoading } = useQuery({
- queryKey: ["editing-templates", filterCategory, searchText],
- queryFn: () =>
- getEditingTemplates({
- category: filterCategory || undefined,
- tag: searchText || undefined,
- }),
- })
-
- const { data: categories = [] } = useQuery({
- queryKey: ["template-categories"],
- queryFn: getTemplateCategories,
- })
-
- /* ── Mutations ── */
- const deleteMutation = useMutation({
- mutationFn: deleteEditingTemplate,
- onSuccess: () => {
- message.success("模板已删除")
- queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
- },
- onError: (err: unknown) => {
- if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("删除失败")
- },
- })
-
- const copyMutation = useMutation({
- mutationFn: (tpl: EditingTemplate) =>
- createEditingTemplate({
- name: `${tpl.name}(副本)`,
- mode: tpl.mode,
- category: tpl.category,
- tags: tpl.tags,
- title_config: tpl.title_config,
- subtitle_config: tpl.subtitle_config,
- bgm_config: tpl.bgm_config,
- estimated_duration:
- tpl.estimated_duration ??
- Math.round(
- tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0),
- ),
- segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
- }),
- onSuccess: () => {
- message.success("模板已复制")
- queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
- },
- onError: (err: unknown) => {
- if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("复制失败")
- },
- })
-
- /* ── 操作 ── */
const handleEdit = (tpl: EditingTemplate) => {
navigate(`/editing-planner?template=${tpl.id}`)
}
const handleGenerate = (tpl: EditingTemplate) => {
- // 跳转到智能剪辑页面,统一从智能剪辑出片
navigate(`/generate?templateId=${tpl.id}`)
}
- const handleCopy = (tpl: EditingTemplate) => {
- copyMutation.mutate(tpl)
- }
-
- const handleDelete = (id: string) => {
- deleteMutation.mutate(id)
- }
-
return (
{/* 页面头部 */}
@@ -179,69 +92,13 @@ const MyTemplates: React.FC = () => {
{templates.map((tpl) => (
-
- handleEdit(tpl)} />
- ,
-
- handleCopy(tpl)} />
- ,
-
- handleGenerate(tpl)} />
- ,
- handleDelete(tpl.id)}
- okText="删除"
- cancelText="取消"
- >
-
-
-
- ,
- ]}
- >
-
-
- {tpl.name}
-
-
- {MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
-
- 用户自制
-
-
-
-
- {tpl.segments.length} 片段 · 预估 ~{tpl.estimated_duration}s
-
- {tpl.category && (
- {tpl.category}
- )}
-
-
- {tpl.tags.length > 0 && (
-
- {tpl.tags.map((tag) => (
-
- {tag}
-
- ))}
-
- )}
-
-
-
- {tpl.title_config.ai_auto_select && AI标题}
- {tpl.subtitle_config.enabled && 字幕}
- {tpl.bgm_config.enabled && BGM}
-
-
-
+
))}
diff --git a/apps/web/src/pages/my-templates/components/TemplateCard.tsx b/apps/web/src/pages/my-templates/components/TemplateCard.tsx
new file mode 100644
index 000000000..2979228c3
--- /dev/null
+++ b/apps/web/src/pages/my-templates/components/TemplateCard.tsx
@@ -0,0 +1,92 @@
+import React from "react"
+import { Card, Tag, Tooltip, Popconfirm, Space, Typography } from "antd"
+import { EditOutlined, CopyOutlined, DeleteOutlined, VideoCameraOutlined } from "@ant-design/icons"
+import {
+ MODE_LABELS,
+ MODE_COLORS,
+ type EditingTemplate,
+ type TemplateMode,
+} from "@/api/editing-planner"
+
+const { Text } = Typography
+
+interface TemplateCardProps {
+ tpl: EditingTemplate
+ onEdit: (tpl: EditingTemplate) => void
+ onCopy: (tpl: EditingTemplate) => void
+ onGenerate: (tpl: EditingTemplate) => void
+ onDelete: (id: string) => void
+}
+
+/**
+ * 单个模板卡片组件
+ */
+export const TemplateCard: React.FC
= ({
+ tpl,
+ onEdit,
+ onCopy,
+ onGenerate,
+ onDelete,
+}) => (
+
+ onEdit(tpl)} />
+ ,
+
+ onCopy(tpl)} />
+ ,
+
+ onGenerate(tpl)} />
+ ,
+ onDelete(tpl.id)}
+ okText="删除"
+ cancelText="取消"
+ >
+
+
+
+ ,
+ ]}
+ >
+
+
+ {tpl.name}
+
+
+ {MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
+
+ 用户自制
+
+
+
+
+ {tpl.segments.length} 片段 · 预估 ~{tpl.estimated_duration}s
+
+ {tpl.category && {tpl.category}}
+
+
+ {tpl.tags.length > 0 && (
+
+ {tpl.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+
+ {tpl.title_config.ai_auto_select && AI标题}
+ {tpl.subtitle_config.enabled && 字幕}
+ {tpl.bgm_config.enabled && BGM}
+
+
+
+)
diff --git a/apps/web/src/pages/my-templates/hooks/useMyTemplates.ts b/apps/web/src/pages/my-templates/hooks/useMyTemplates.ts
new file mode 100644
index 000000000..76610297c
--- /dev/null
+++ b/apps/web/src/pages/my-templates/hooks/useMyTemplates.ts
@@ -0,0 +1,100 @@
+import { useState } from "react"
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
+import { message } from "antd"
+import {
+ getEditingTemplates,
+ getTemplateCategories,
+ deleteEditingTemplate,
+ createEditingTemplate,
+ type EditingTemplate,
+} from "@/api/editing-planner"
+
+/**
+ * 我的模板数据 Hook
+ * 封装模板列表查询、筛选、删除、复制等数据操作
+ */
+export function useMyTemplates() {
+ const queryClient = useQueryClient()
+ const [searchText, setSearchText] = useState("")
+ const [filterCategory, setFilterCategory] = useState("")
+
+ /* 模板列表 */
+ const { data: templates = [], isLoading } = useQuery({
+ queryKey: ["editing-templates", filterCategory, searchText],
+ queryFn: () =>
+ getEditingTemplates({
+ category: filterCategory || undefined,
+ tag: searchText || undefined,
+ }),
+ })
+
+ /* 分类列表 */
+ const { data: categories = [] } = useQuery({
+ queryKey: ["template-categories"],
+ queryFn: getTemplateCategories,
+ })
+
+ /* 删除 mutation */
+ const deleteMutation = useMutation({
+ mutationFn: deleteEditingTemplate,
+ onSuccess: () => {
+ message.success("模板已删除")
+ queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
+ },
+ onError: (err: unknown) => {
+ if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("删除失败")
+ },
+ })
+
+ /* 复制 mutation */
+ const copyMutation = useMutation({
+ mutationFn: (tpl: EditingTemplate) =>
+ createEditingTemplate({
+ name: `${tpl.name}(副本)`,
+ mode: tpl.mode,
+ category: tpl.category,
+ tags: tpl.tags,
+ title_config: tpl.title_config,
+ subtitle_config: tpl.subtitle_config,
+ bgm_config: tpl.bgm_config,
+ estimated_duration:
+ tpl.estimated_duration ??
+ Math.round(
+ tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0),
+ ),
+ segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
+ }),
+ onSuccess: () => {
+ message.success("模板已复制")
+ queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
+ },
+ onError: (err: unknown) => {
+ if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("复制失败")
+ },
+ })
+
+ const handleCopy = (tpl: EditingTemplate) => {
+ copyMutation.mutate(tpl)
+ }
+
+ const handleDelete = (id: string) => {
+ deleteMutation.mutate(id)
+ }
+
+ return {
+ // 状态
+ searchText,
+ setSearchText,
+ filterCategory,
+ setFilterCategory,
+ // 数据
+ templates,
+ categories,
+ isLoading,
+ // 操作
+ handleCopy,
+ handleDelete,
+ isDeleting: deleteMutation.isPending,
+ isCopying: copyMutation.isPending,
+ }
+}
diff --git a/apps/web/src/pages/subscription/UpgradeSubscription.tsx b/apps/web/src/pages/subscription/UpgradeSubscription.tsx
index 720beeb2d..36033d6b0 100644
--- a/apps/web/src/pages/subscription/UpgradeSubscription.tsx
+++ b/apps/web/src/pages/subscription/UpgradeSubscription.tsx
@@ -2,151 +2,59 @@
* 升级/降级/续费页面
* P1-3: antd Button/Modal/Radio/Spin → 自定义 UI 组件
*/
-import React, { useState, useEffect } from "react"
-import { message } from "antd"
-import { Button, Modal } from "@/components/ui"
+import React from "react"
+import { Modal } from "@/components/ui"
import { useNavigate } from "react-router-dom"
-import {
- getCurrentSubscription,
- changePlan,
- toggleAutoRenew,
- cancelSubscription,
-} from "@/api/subscription"
-import type { SubscriptionInfo, PlanType, BillingCycle } from "@/api/subscription"
+import type { PlanType } from "@/api/subscription"
import PageHead from "@/components/layout/PageHead"
+import { Button } from "@/components/ui"
+import { PLANS_META, getPlanName, getPlanPrice } from "./constants"
+import { BillingCycleSwitch, Spinner } from "./components/SubscriptionUI"
+import { useSubscription } from "./hooks/useSubscription"
import "./UpgradeSubscription.css"
-const PLANS_META: Record = {
- free: { name: "体验版", price: 0, yearlyPrice: 0 },
- standard: { name: "标准版", price: 99, yearlyPrice: 990 },
- pro: { name: "专业版", price: 299, yearlyPrice: 2990 },
- enterprise: { name: "企业版", price: 0, yearlyPrice: 0 },
-}
-
-/** 自定义计费周期切换组件 */
-const BillingCycleSwitch: React.FC<{
- value: BillingCycle
- onChange: (cycle: BillingCycle) => void
- monthlyPrice: number
- yearlyPrice: number
-}> = ({ value, onChange, monthlyPrice, yearlyPrice }) => (
-
-
-
-
-)
-
-/** 自定义 Spinner 组件 */
-const Spinner: React.FC<{ size?: "small" | "large" }> = ({ size = "large" }) => (
-
-)
-
const UpgradeSubscription: React.FC = () => {
const navigate = useNavigate()
- const [subscription, setSubscription] = useState(null)
- const [loading, setLoading] = useState(true)
- const [submitting, setSubmitting] = useState(false)
- const [selectedPlan, setSelectedPlan] = useState("standard")
- const [billingCycle, setBillingCycle] = useState("monthly")
+ const {
+ subscription,
+ loading,
+ submitting,
+ selectedPlan,
+ billingCycle,
+ setSelectedPlan,
+ setBillingCycle,
+ executeChangePlan,
+ handleToggleAutoRenew,
+ handleCancel,
+ } = useSubscription()
- useEffect(() => {
- loadSubscription()
- }, [])
-
- const loadSubscription = async () => {
- try {
- const data = await getCurrentSubscription()
- setSubscription(data)
- setSelectedPlan(data.plan_id)
- } catch (err: unknown) {
- if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("获取订阅信息失败")
- } finally {
- setLoading(false)
- }
- }
-
- const handleUpgrade = async () => {
+ const handleUpgradeClick = () => {
if (!subscription) return
if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) {
- message.info("当前已是该套餐")
return
}
const plan = PLANS_META[selectedPlan]
- const price = billingCycle === "yearly" ? plan.yearlyPrice : plan.price
+ const price = getPlanPrice(selectedPlan, billingCycle)
Modal.confirm({
title: "确认变更套餐",
content: `即将变更为「${plan.name}」(${billingCycle === "monthly" ? "月付" : "年付"}),${price > 0 ? `费用 ¥${price}${billingCycle === "monthly" ? "/月" : "/年"}` : "免费"}。变更立即生效。`,
okText: "确认变更",
cancelText: "取消",
- onOk: async () => {
- try {
- setSubmitting(true)
- const res = await changePlan({
- target_plan_id: selectedPlan,
- billing_cycle: billingCycle,
- })
- if (res.success) {
- message.success(res.message)
- setSubscription(res.new_subscription ?? null)
- } else {
- message.error(res.message)
- }
- } catch (err: unknown) {
- if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("套餐变更失败,请重试")
- } finally {
- setSubmitting(false)
- }
- },
+ onOk: executeChangePlan,
})
}
- const handleToggleAutoRenew = async (enabled: boolean) => {
- try {
- const res = await toggleAutoRenew(enabled)
- message.success(res.message)
- if (subscription) {
- setSubscription({ ...subscription, auto_renew: enabled })
- }
- } catch (err: unknown) {
- if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败")
- }
- }
-
- const handleCancel = () => {
+ const handleCancelClick = () => {
Modal.confirm({
title: "确认取消订阅",
content: "取消后,当前周期结束前仍可正常使用,到期后降级为体验版。",
okText: "确认取消",
cancelText: "再想想",
onOk: async () => {
- try {
- const res = await cancelSubscription()
- message.success(res.message)
- navigate("/app/subscription")
- } catch (err: unknown) {
- if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("取消失败")
- }
+ const ok = await handleCancel()
+ if (ok) navigate("/app/subscription")
},
})
}
@@ -163,10 +71,7 @@ const UpgradeSubscription: React.FC = () => {
return (
-
+
{(["standard", "pro", "enterprise"] as PlanType[]).map((planId) => {
@@ -198,7 +103,7 @@ const UpgradeSubscription: React.FC = () => {
buttonType="primary"
buttonSize="lg"
disabled={submitting || selectedPlan === currentPlan}
- onClick={handleUpgrade}
+ onClick={handleUpgradeClick}
>
{submitting ? "处理中..." : "确认变更"}
@@ -220,7 +125,7 @@ const UpgradeSubscription: React.FC = () => {