上线版: 产品表单统一+form嵌套修复+用户管理+部署+三套叙事
- 产品编辑入口统一走 ProductFormFull(卖点/风格/人群/品牌词全字段); 修复开任务页 <form> 套 <form> 致"编辑产品"报错、改不了、跳回首个产品 - dashboard 入口卡片对齐实际路由: 系统管理(/config) 与 工作配置(/settings) 分开; settings ?tab=products 直达改用挂载后读 URL, 消除 hydration mismatch - 新增用户管理(users API/admin service/改密页) + alembic 022/023/024 - 上线部署: Dockerfile / docker-compose.prod+https / nginx https / .env.example - A8 三套正交叙事(痛点/场景/成分背书) + beige 调色去AI化 + 飞轮 text_import 高权重信号 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
9
frontend/.dockerignore
Normal file
9
frontend/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
.DS_Store
|
||||
4
frontend/.env.example
Normal file
4
frontend/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# Same-origin nginx deployment can keep these empty.
|
||||
# These are build-time variables. After changing them, rebuild the frontend image.
|
||||
NEXT_PUBLIC_API_BASE_URL=
|
||||
NEXT_PUBLIC_SSE_BASE_URL=
|
||||
28
frontend/Dockerfile
Normal file
28
frontend/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ARG NEXT_PUBLIC_API_BASE_URL
|
||||
ARG NEXT_PUBLIC_SSE_BASE_URL
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_SSE_BASE_URL=$NEXT_PUBLIC_SSE_BASE_URL
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY package.json package-lock.json ./
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/next.config.js ./next.config.js
|
||||
RUN npm prune --omit=dev
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -1,6 +1,6 @@
|
||||
# Clover Frontend
|
||||
|
||||
Next.js(React)— 5屏骨架
|
||||
Next.js(React)— 运营端
|
||||
|
||||
## 目录规划
|
||||
```
|
||||
@@ -11,14 +11,15 @@ frontend/src/
|
||||
types/ # TypeScript 类型
|
||||
```
|
||||
|
||||
## 5屏
|
||||
1. 管理配置台 — 产品档案/标杆笔记/违禁词/API Key录入
|
||||
2. 开任务 — 选产品+标杆+今天主题+设定数量,发起生成
|
||||
3. 挑文案+挑图 — SSE实时看生成进度,8选3文案/挑图,显示"本次已注入"
|
||||
4. 确认预览 — 提交审核
|
||||
5. 审核台(组长) — 待审队列/通过/打回写原因
|
||||
## 主要页面
|
||||
当前 App Router 生产构建包含 13 个路由:首页、看板、配置、仪表盘、裂变、历史、登录、审核、设置、任务创建、文案选择、图片选择、确认预览。
|
||||
|
||||
## 前端红线(不得违反)
|
||||
- 不展示 Token 余额/用量
|
||||
- 不做积分中心/billing 页
|
||||
- 飞轮偏好信号不暴露独立 POST 给前端(防伪造)
|
||||
|
||||
## 环境变量
|
||||
- `NEXT_PUBLIC_API_BASE_URL`:API 基地址;同域 nginx 部署可留空。
|
||||
- `NEXT_PUBLIC_SSE_BASE_URL`:SSE 后端地址;同域 nginx 部署可留空。
|
||||
- 这两个变量是构建期变量,修改后必须重新 build 前端镜像。
|
||||
|
||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
|
||||
const config = [
|
||||
...nextVitals,
|
||||
{
|
||||
ignores: ['.next/**', 'node_modules/**'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'react-hooks/immutability': 'off',
|
||||
'react-hooks/purity': 'off',
|
||||
'react-hooks/refs': 'off',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default config;
|
||||
3
frontend/next-env.d.ts
vendored
3
frontend/next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
turbopack: {
|
||||
root: __dirname,
|
||||
},
|
||||
images: {
|
||||
domains: ['localhost'],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
},
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
|
||||
|
||||
2408
frontend/package-lock.json
generated
2408
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,13 +4,13 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"next": "14.2.0",
|
||||
"next": "^16.2.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
@@ -21,10 +21,13 @@
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "14.2.0",
|
||||
"postcss": "^8.4.38",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.2.9",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "^8.5.15"
|
||||
}
|
||||
}
|
||||
|
||||
1
frontend/public/.gitkeep
Normal file
1
frontend/public/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 复用 history 的状态色 + Link 跳转风格,保持视觉一致不割裂。
|
||||
*/
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { TaskListItem } from '@/types';
|
||||
|
||||
@@ -18,8 +19,11 @@ export interface ColumnDef {
|
||||
|
||||
export const BOARD_COLUMNS: ColumnDef[] = [
|
||||
{ key: 'generating', label: '生成中',
|
||||
statuses: ['pending', 'generating', 'pending_selection'],
|
||||
statuses: ['pending', 'generating'],
|
||||
accent: 'text-blue-600 bg-blue-50' },
|
||||
{ key: 'pending_selection', label: '待提交',
|
||||
statuses: ['pending_selection'],
|
||||
accent: 'text-indigo-600 bg-indigo-50' },
|
||||
{ key: 'review', label: '待审核',
|
||||
statuses: ['pending_review'], accent: 'text-amber-600 bg-amber-50' },
|
||||
{ key: 'approved', label: '已通过',
|
||||
@@ -28,42 +32,78 @@ export const BOARD_COLUMNS: ColumnDef[] = [
|
||||
statuses: ['rejected'], accent: 'text-red-500 bg-red-50' },
|
||||
];
|
||||
|
||||
// 卡片点击目标:生成中/待审核进 confirm 看文案,其余进 images 排图/查看
|
||||
// 卡片点击目标:除「待提交(pending_selection)」进 images 排图外,其余都进 confirm
|
||||
// (生成中/待审核=看文案进度,已通过/已打回=只读查看文案+图)
|
||||
function cardHref(task: TaskListItem): string {
|
||||
if (['pending', 'generating', 'pending_selection', 'pending_review'].includes(task.status)) {
|
||||
return `/tasks/${task.id}/confirm`;
|
||||
if (task.status === 'pending_selection') {
|
||||
return `/tasks/${task.id}/images`;
|
||||
}
|
||||
return `/tasks/${task.id}/images`;
|
||||
return `/tasks/${task.id}/confirm`;
|
||||
}
|
||||
|
||||
// --- TaskCard --- 单个任务卡,点击进详情
|
||||
export function TaskCard({ task }: { task: TaskListItem }) {
|
||||
// onDelete 可选:仅管理员且非「待审核」列传入 → 卡右上角出删除按钮(先确认再删)
|
||||
export function TaskCard({ task, onDelete }: { task: TaskListItem; onDelete?: (id: number) => void }) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
return (
|
||||
<Link
|
||||
href={cardHref(task)}
|
||||
aria-label={`任务 ${task.theme}`}
|
||||
className="block rounded-lg border border-border-default bg-white p-3 hover:border-brand-orange hover:shadow-sm transition-all"
|
||||
>
|
||||
<p className="text-sm font-medium text-text-primary line-clamp-2">{task.theme}</p>
|
||||
<p className="mt-1 text-xs text-text-secondary">{task.product_name ?? '—'}</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-text-tertiary">
|
||||
<span>{task.text_count} 文</span>
|
||||
<span>·</span>
|
||||
<span>{task.image_count} 图</span>
|
||||
<span className="ml-auto">{new Date(task.created_at).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
{/* 被打回任务:列内直接显示原因摘要,运营一眼看到为何被打回 */}
|
||||
{task.status === 'rejected' && task.reject_reason && (
|
||||
<p className="mt-2 rounded bg-red-50 px-2 py-1 text-xs text-red-600 line-clamp-2">
|
||||
{task.reject_reason}
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Link
|
||||
href={cardHref(task)}
|
||||
aria-label={`任务 ${task.theme}`}
|
||||
className="block rounded-lg border border-border-default bg-white p-3 hover:border-brand-orange hover:shadow-sm transition-all"
|
||||
>
|
||||
<p className="text-sm font-medium text-text-primary line-clamp-2 pr-6">{task.theme}</p>
|
||||
<p className="mt-1 text-xs text-text-secondary">{task.product_name ?? '—'}</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-text-tertiary">
|
||||
<span>{task.text_count} 文</span>
|
||||
<span>·</span>
|
||||
<span>{task.image_count} 图</span>
|
||||
<span className="ml-auto">{new Date(task.created_at).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
{/* 被打回任务:列内直接显示原因摘要,运营一眼看到为何被打回 */}
|
||||
{task.status === 'rejected' && task.reject_reason && (
|
||||
<p className="mt-2 rounded bg-red-50 px-2 py-1 text-xs text-red-600 line-clamp-2">
|
||||
{task.reject_reason}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
{/* 删除按钮(管理员,非待审核列):浮在 Link 外层,点击不触发跳转 */}
|
||||
{onDelete && !confirming && (
|
||||
<button
|
||||
onClick={() => setConfirming(true)}
|
||||
className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded text-text-tertiary hover:bg-red-50 hover:text-red-500"
|
||||
aria-label="删除任务"
|
||||
title="删除任务"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</Link>
|
||||
{onDelete && confirming && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg bg-white/95 p-2 text-center">
|
||||
<p className="text-xs text-text-primary">确定删除这个任务?</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
className="rounded border border-border-default px-2 py-0.5 text-xs text-text-secondary hover:bg-surface-secondary"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onDelete(task.id); }}
|
||||
className="rounded bg-red-500 px-2 py-0.5 text-xs text-white hover:bg-red-600"
|
||||
>
|
||||
确认删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- BoardColumn --- 状态列:列头 + 计数 + 卡片堆叠
|
||||
export function BoardColumn({ col, tasks }: { col: ColumnDef; tasks: TaskListItem[] }) {
|
||||
// onDelete 可选:传入则该列卡片显删除按钮(page 仅对管理员+非待审核列传)
|
||||
export function BoardColumn({ col, tasks, onDelete }: { col: ColumnDef; tasks: TaskListItem[]; onDelete?: (id: number) => void }) {
|
||||
return (
|
||||
<div className="flex flex-col min-w-[240px] flex-1 rounded-xl bg-surface-secondary p-3">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
@@ -76,7 +116,7 @@ export function BoardColumn({ col, tasks }: { col: ColumnDef; tasks: TaskListIte
|
||||
{tasks.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-text-tertiary">暂无任务</p>
|
||||
) : (
|
||||
tasks.map((t) => <TaskCard key={t.id} task={t} />)
|
||||
tasks.map((t) => <TaskCard key={t.id} task={t} onDelete={onDelete} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { TaskListItem, PaginatedResponse } from '@/types';
|
||||
import { getErrorAction } from '@/types/errors';
|
||||
import { BOARD_COLUMNS, BoardColumn, BoardSkeleton } from './components';
|
||||
@@ -20,6 +21,8 @@ export default function BoardPage() {
|
||||
const [tasks, setTasks] = useState<TaskListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [productId, setProductId] = useState<number | 'all'>('all'); // 顶部产品筛选
|
||||
const isAdmin = useAuthStore((s) => s.role) === 'admin'; // 删除按钮仅管理员可见
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -39,10 +42,27 @@ export default function BoardPage() {
|
||||
|
||||
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
||||
|
||||
// 按列分组:每列收自己 statuses 命中的任务
|
||||
// 删除整个任务(仅管理员):后端智能删(有成品→归档保留,残次→物理删),成功后本地移除卡片
|
||||
const handleDelete = useCallback(async (id: number) => {
|
||||
try {
|
||||
await api.delete(`/api/v1/tasks/${id}`);
|
||||
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||
} catch (e: unknown) {
|
||||
const ae = e as { code?: number };
|
||||
setError(getErrorAction(ae.code ?? 50001).message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 任务里出现过的产品去重,喂给顶部下拉(数据已带 product_id/product_name,不改后端)
|
||||
const products = Array.from(
|
||||
new Map(tasks.map((t) => [t.product_id, t.product_name ?? `产品#${t.product_id}`])).entries(),
|
||||
).map(([id, name]) => ({ id, name }));
|
||||
|
||||
// 先按选中产品过滤,再按列分组
|
||||
const visibleTasks = productId === 'all' ? tasks : tasks.filter((t) => t.product_id === productId);
|
||||
const byColumn = BOARD_COLUMNS.map((col) => ({
|
||||
col,
|
||||
items: tasks.filter((t) => col.statuses.includes(t.status)),
|
||||
items: visibleTasks.filter((t) => col.statuses.includes(t.status)),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -58,6 +78,21 @@ export default function BoardPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="board-product" className="text-sm text-text-secondary">按产品筛选</label>
|
||||
<select
|
||||
id="board-product"
|
||||
value={productId}
|
||||
onChange={(e) => setProductId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
||||
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-primary focus:outline-none focus:border-brand-orange"
|
||||
>
|
||||
<option value="all">全部产品</option>
|
||||
{products.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
|
||||
{error}
|
||||
@@ -70,7 +105,13 @@ export default function BoardPage() {
|
||||
) : (
|
||||
<div className="flex gap-4 flex-1 overflow-x-auto">
|
||||
{byColumn.map(({ col, items }) => (
|
||||
<BoardColumn key={col.key} col={col} tasks={items} />
|
||||
<BoardColumn
|
||||
key={col.key}
|
||||
col={col}
|
||||
tasks={items}
|
||||
/* 删除按钮:仅管理员,且「待审核」列不放(审核中不允许删) */
|
||||
onDelete={isAdmin && col.key !== 'review' ? handleDelete : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
94
frontend/src/app/change-password/page.tsx
Normal file
94
frontend/src/app/change-password/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { ApiError } from '@/types';
|
||||
|
||||
const INPUT_CLS = 'w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange transition-colors';
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const router = useRouter();
|
||||
const { user, fetchMe } = useAuthStore();
|
||||
const mustChange = !!user?.must_change_password;
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (newPassword.length < 8) {
|
||||
setError('新密码至少 8 位');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.post('/api/v1/auth/change-password', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
await fetchMe();
|
||||
if (mustChange) {
|
||||
router.replace(user?.role === 'admin' ? '/config' : user?.role === 'supervisor' ? '/review' : '/tasks/new');
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
} catch (err) {
|
||||
const apiErr = err as ApiError;
|
||||
setError(apiErr?.message || '修改密码失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[calc(100vh-56px)] bg-brand-cream">
|
||||
<div className="w-96 bg-surface-primary rounded-2xl shadow-lg p-8">
|
||||
<h1 className="text-xl font-bold text-text-primary mb-2">
|
||||
{mustChange ? '首次登录请修改密码' : '修改密码'}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mb-6">
|
||||
{mustChange
|
||||
? '为了保护客户数据,初始密码只能用于首次登录。'
|
||||
: '建议定期更换密码,新密码至少 8 位。'}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label htmlFor="current-password" className="block text-sm font-medium text-text-primary mb-1">当前密码</label>
|
||||
<input id="current-password" type="password" autoComplete="current-password" value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)} className={INPUT_CLS} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new-password" className="block text-sm font-medium text-text-primary mb-1">新密码</label>
|
||||
<input id="new-password" type="password" autoComplete="new-password" value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)} className={INPUT_CLS} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirm-password" className="block text-sm font-medium text-text-primary mb-1">确认新密码</label>
|
||||
<input id="confirm-password" type="password" autoComplete="new-password" value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)} className={INPUT_CLS} />
|
||||
</div>
|
||||
{error && <div role="alert" className="text-status-error text-sm bg-red-50 px-3 py-2 rounded-lg">{error}</div>}
|
||||
<button type="submit" disabled={saving}
|
||||
className="w-full bg-brand-orange text-white rounded-lg py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50">
|
||||
{saving ? '保存中…' : '修改密码'}
|
||||
</button>
|
||||
{!mustChange && (
|
||||
<button type="button" onClick={() => router.back()}
|
||||
className="w-full text-text-secondary rounded-lg py-2 text-sm hover:text-text-primary transition-colors">
|
||||
返回
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,14 @@
|
||||
import { useState } from 'react';
|
||||
import { BenchmarksTab } from '@/components/config/BenchmarksTab';
|
||||
import { BannedWordsTab } from '@/components/config/BannedWordsTab';
|
||||
import { UserManageTab } from '@/components/config/UserManageTab';
|
||||
|
||||
type TabKey = 'benchmarks' | 'banned_words';
|
||||
type TabKey = 'benchmarks' | 'banned_words' | 'users';
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: 'benchmarks', label: '标杆笔记' },
|
||||
{ key: 'banned_words', label: '违禁词库' },
|
||||
{ key: 'users', label: '账号管理' },
|
||||
];
|
||||
|
||||
export default function ConfigPage() {
|
||||
@@ -46,6 +48,7 @@ export default function ConfigPage() {
|
||||
<div id={`panel-${activeTab}`} role="tabpanel">
|
||||
{activeTab === 'benchmarks' && <BenchmarksTab />}
|
||||
{activeTab === 'banned_words' && <BannedWordsTab />}
|
||||
{activeTab === 'users' && <UserManageTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,8 +28,9 @@ export default function DashboardPage() {
|
||||
<DashCard href="/review" icon="✅" label="审核台" desc="查看待审笔记,通过或打回" />
|
||||
)}
|
||||
{role === 'admin' && (
|
||||
<DashCard href="/config" icon="⚙️" label="配置中心" desc="管理产品、标杆笔记、违禁词和 API Key" />
|
||||
<DashCard href="/config" icon="⚙️" label="系统管理" desc="标杆笔记、违禁词库、账号管理" />
|
||||
)}
|
||||
<DashCard href="/settings" icon="🔧" label="工作配置" desc="产品档案、API Key" />
|
||||
<DashCard href="/history" icon="🗂️" label="历史归档" desc="查看历史任务和成品库" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ function DownloadButton({ taskId }: { taskId: number }) {
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
await api.post(`/api/v1/delivery-packages/${id}/mark-downloaded`);
|
||||
setState('done');
|
||||
return;
|
||||
}
|
||||
@@ -55,7 +56,11 @@ function DownloadButton({ taskId }: { taskId: number }) {
|
||||
} catch { setState('error'); }
|
||||
}
|
||||
|
||||
if (state === 'done') return <span className="text-xs text-green-600">已下载</span>;
|
||||
if (state === 'done') return (
|
||||
<span className="text-xs text-green-600" title="文件已保存到浏览器默认下载目录(通常是「下载」文件夹)">
|
||||
✓ 已下载到「下载」文件夹
|
||||
</span>
|
||||
);
|
||||
if (state === 'error') return <span className="text-xs text-red-500">失败,刷新重试</span>;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 数据:GET /api/v1/tasks?status=approved,archived,rejected
|
||||
*/
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { TaskListItem, PaginatedResponse } from '@/types';
|
||||
import { getErrorAction } from '@/types/errors';
|
||||
@@ -19,6 +20,8 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function HistoryPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const isImageView = searchParams.get('view') === 'images';
|
||||
const [tasks, setTasks] = useState<TaskListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -58,10 +61,18 @@ export default function HistoryPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-text-primary">历史归档</h1>
|
||||
<h1 className="text-xl font-semibold text-text-primary">
|
||||
{isImageView ? '图片排版' : '历史归档'}
|
||||
</h1>
|
||||
<span className="text-sm text-text-tertiary">共 {total} 条</span>
|
||||
</div>
|
||||
|
||||
{isImageView && (
|
||||
<p className="text-sm text-text-secondary -mt-2">
|
||||
图片排版需先选一条任务:在下方列表点「排图」进入该任务的图片步骤。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<div className="flex gap-2" role="group" aria-label="状态筛选">
|
||||
{[{ key: 'all', label: '全部' }, ...HISTORY_STATUSES.map((s) => ({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '龙石小红书内容生产平台',
|
||||
title: 'Clover小红书内容生产平台',
|
||||
description: '小红书内容生产工具',
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function RootLayout({
|
||||
{/* 顶部 Header */}
|
||||
<header className="h-14 bg-brand-orange flex items-center px-6 justify-between flex-shrink-0">
|
||||
<h1 className="text-white font-bold text-base">
|
||||
龙石小红书内容生产平台
|
||||
Clover小红书内容生产平台
|
||||
</h1>
|
||||
<UserBadge />
|
||||
</header>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
// /login — 登录页,按角色跳转首页(admin→/config, supervisor→/review, operator→/tasks/new)
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -15,6 +15,14 @@ export default function LoginPage() {
|
||||
const [form, setForm] = useState<LoginRequest>({ username: '', password: '' });
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
// 被 token 失效踢回登录页时给出明确提示(api.ts 跳转会带 ?reason=expired)
|
||||
const [notice, setNotice] = useState('');
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const reason = new URLSearchParams(window.location.search).get('reason');
|
||||
if (reason === 'expired') setNotice('登录状态已过期,请重新登录后再操作。');
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -27,6 +35,10 @@ export default function LoginPage() {
|
||||
try {
|
||||
const data = await api.post<LoginResponse>('/api/v1/auth/login', form);
|
||||
login(data.token, data.user);
|
||||
if (data.user.must_change_password) {
|
||||
router.replace('/change-password');
|
||||
return;
|
||||
}
|
||||
// 按角色跳对应首页
|
||||
const dest =
|
||||
data.user.role === 'admin'
|
||||
@@ -49,11 +61,16 @@ export default function LoginPage() {
|
||||
<div className="w-96 bg-surface-primary rounded-2xl shadow-lg p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-4xl mb-2" aria-hidden="true">🌿</div>
|
||||
<h1 className="text-xl font-bold text-text-primary">龙石内容生产平台</h1>
|
||||
<h1 className="text-xl font-bold text-text-primary">Clover内容生产平台</h1>
|
||||
<p className="text-text-secondary text-sm mt-1">小红书内容工具</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
|
||||
{notice && (
|
||||
<div role="status" className="text-sm bg-amber-50 text-amber-700 border border-amber-200 px-3 py-2 rounded-lg">
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-text-primary mb-1">用户名</label>
|
||||
<input id="username" type="text" autoComplete="username" value={form.username}
|
||||
@@ -63,9 +80,16 @@ export default function LoginPage() {
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-primary mb-1">密码</label>
|
||||
<input id="password" type="password" autoComplete="current-password" value={form.password}
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||
className={INPUT_CLS} aria-required="true" />
|
||||
<div className="relative">
|
||||
<input id="password" type={showPwd ? 'text' : 'password'} autoComplete="current-password" value={form.password}
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||
className={INPUT_CLS + ' pr-10'} aria-required="true" />
|
||||
<button type="button" onClick={() => setShowPwd((v) => !v)}
|
||||
aria-label={showPwd ? '隐藏密码' : '显示密码'}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-secondary hover:text-text-primary text-base p-1">
|
||||
{showPwd ? '👁️' : '🙈'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 🔴 红线:key 各人自录自用各算各的;只录不显余额
|
||||
* 拆分自原 /config(B方案):admin 专属的标杆/违禁词留在 /config「系统管理」
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ProductsTab } from '@/components/config/ProductsTab';
|
||||
import { ApiKeysTab } from '@/components/config/ApiKeysTab';
|
||||
|
||||
@@ -18,6 +18,12 @@ const TABS: { key: TabKey; label: string }[] = [
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('api_keys');
|
||||
// 支持 ?tab=products 直达(开任务页「编辑配置」跳来)。
|
||||
// 客户端挂载后再读 URL,避免 SSR(无 window) 与客户端初值不一致引发 hydration mismatch。
|
||||
useEffect(() => {
|
||||
const t = new URLSearchParams(window.location.search).get('tab');
|
||||
if (t === 'products') setActiveTab('products');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl">
|
||||
|
||||
@@ -3,21 +3,82 @@
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { ProgressBar } from '@/components/layout/ProgressBar';
|
||||
import { ConfirmActionBar } from '@/components/tasks/ConfirmActionBar';
|
||||
import { DeleteTaskButton } from '@/components/tasks/DeleteTaskButton';
|
||||
import { ConfirmPreviewImages } from '@/components/tasks/ConfirmPreviewImages';
|
||||
import { ImageLightbox } from '@/components/tasks/ImageLightbox';
|
||||
import { TextLightbox } from '@/components/tasks/TextLightbox';
|
||||
import { useTaskStore } from '@/stores/taskStore';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { api } from '@/lib/api';
|
||||
import { useState } from 'react';
|
||||
import { TextCandidate, ImageCandidate } from '@/types/index';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function ConfirmPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const taskId = params?.id ? Number(params.id) : null;
|
||||
const router = useRouter();
|
||||
const { selectedTextIds, selectedImageIds } = useTaskStore();
|
||||
const { selectedTextIds, selectedImageIds, setSelections } = useTaskStore();
|
||||
// imageCandidates 平铺(契约 image_candidate 事件无 batch 字段)
|
||||
const { textCandidates, imageCandidates } = useGenerationStore();
|
||||
const { textCandidates, imageCandidates, hydrateFromTask } = useGenerationStore();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [hydrating, setHydrating] = useState(false);
|
||||
const [hydrateError, setHydrateError] = useState(false); // C1: 拉取失败时给重试入口
|
||||
const [submitError, setSubmitError] = useState(''); // 提审被后端拒绝时显式提示,不再吞错
|
||||
// 飞轮节点反馈:提交审核/打回后短暂绿芽提示
|
||||
const [flywheelMsg, setFlywheelMsg] = useState<string | null>(null);
|
||||
// 失败任务查看不能空白(#11):记录任务状态+失败原因,failed 时显眼提示
|
||||
const [taskMeta, setTaskMeta] = useState<{ status: string; failReason: string } | null>(null);
|
||||
// 已通过/已打回 = 只读查看态(倩倩姐2026-06-26):不提交审核,文案点看全文、图点放大
|
||||
const isReadonly = taskMeta?.status === 'approved' || taskMeta?.status === 'rejected';
|
||||
const [zoomImage, setZoomImage] = useState<ImageCandidate | null>(null);
|
||||
const [zoomText, setZoomText] = useState<TextCandidate | null>(null);
|
||||
|
||||
// store 无持久化:从历史归档「查看」直达本页时 store 是空的 → 「暂无选中文案」。
|
||||
// 挂载时拉 GET /tasks/{id}:候选为空则回填+据 is_selected 还原已选;
|
||||
// 同时记录任务状态/失败原因,失败任务不再空白展示(#11)。
|
||||
const loadTask = (id: number) => {
|
||||
// 跨任务切换修复(倩倩姐2026-06-26报bug):SPA 路由切到不同任务卡片时,
|
||||
// 上个任务数据仍在 store → storeEmpty=false → 旧逻辑直接 return 显示上一个任务,
|
||||
// 手动刷新才好(刷新=重挂载清空 store)。这里按 currentTaskId 判定是否换了任务,
|
||||
// 换了就先清两个 store,让本次走"空 store 全量回填"路径,根治"显示上个任务/暂无选中"。
|
||||
const ts = useTaskStore.getState();
|
||||
if (ts.currentTaskId !== id) {
|
||||
useGenerationStore.getState().reset();
|
||||
ts.reset();
|
||||
ts.setCurrentTask(id, 'pending_selection');
|
||||
}
|
||||
const storeEmpty =
|
||||
useGenerationStore.getState().textCandidates.length === 0 &&
|
||||
useGenerationStore.getState().imageCandidates.length === 0;
|
||||
if (storeEmpty) setHydrating(true);
|
||||
setHydrateError(false);
|
||||
api.get<{
|
||||
status: string; reject_reason?: string | null;
|
||||
text_candidates: TextCandidate[]; image_candidates: ImageCandidate[];
|
||||
}>(`/api/v1/tasks/${id}`)
|
||||
.then((data) => {
|
||||
setTaskMeta({ status: data.status, failReason: data.reject_reason || '' });
|
||||
if (!storeEmpty) return;
|
||||
const texts = data.text_candidates || [];
|
||||
const images = data.image_candidates || [];
|
||||
hydrateFromTask({ textCandidates: texts, imageCandidates: images });
|
||||
// hydrateFromTask 内已据 is_selected 写 taskStore;但 storeEmpty 路径
|
||||
// generationStore 可能比 taskStore setSelections 先跑,兜底再调一次
|
||||
setSelections(
|
||||
texts.filter((c) => c.is_selected).map((c) => c.candidate_id),
|
||||
images.filter((c) => c.is_selected).map((c) => c.candidate_id),
|
||||
);
|
||||
})
|
||||
.catch(() => { setHydrateError(true); }) // C1: 不再静默,给重试入口
|
||||
.finally(() => setHydrating(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
loadTask(taskId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskId]);
|
||||
|
||||
const selectedTexts = textCandidates.filter((c) => selectedTextIds.includes(c.candidate_id));
|
||||
const selectedImages = imageCandidates.filter((c) => selectedImageIds.includes(c.candidate_id));
|
||||
@@ -25,9 +86,17 @@ export default function ConfirmPage() {
|
||||
async function handleSubmitReview() {
|
||||
if (!taskId) return;
|
||||
setSubmitting(true);
|
||||
setSubmitError('');
|
||||
try {
|
||||
await api.post(`/api/v1/tasks/${taskId}/submit-review`);
|
||||
router.push('/review');
|
||||
setFlywheelMsg('🌿 已学习你的选择,下次更懂你');
|
||||
setTimeout(() => {
|
||||
setFlywheelMsg(null);
|
||||
router.push('/review');
|
||||
}, 2500);
|
||||
} catch (e) {
|
||||
// 后端校验失败(如未选满)以前被 finally 吞掉 → 用户看着像"点了没反应"
|
||||
setSubmitError(e instanceof Error ? e.message : '提交审核失败,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -38,7 +107,11 @@ export default function ConfirmPage() {
|
||||
setRegenerating(true);
|
||||
try {
|
||||
await api.post(`/api/v1/tasks/${taskId}/regenerate`);
|
||||
router.push(`/tasks/${taskId}/text`);
|
||||
setFlywheelMsg('🌿 已记录你的顾虑,下次会避开');
|
||||
setTimeout(() => {
|
||||
setFlywheelMsg(null);
|
||||
router.push(`/tasks/${taskId}/text`);
|
||||
}, 2500);
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
@@ -46,27 +119,66 @@ export default function ConfirmPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ProgressBar currentStep={4} />
|
||||
{/* 已通过/已打回=只读查看,不显示步骤进度条(倩倩姐2026-06-26) */}
|
||||
{!isReadonly && <ProgressBar currentStep={4} />}
|
||||
|
||||
<div className="p-6 flex-1 overflow-auto max-w-4xl">
|
||||
<h2 className="text-xl font-bold text-text-primary mb-6">确认预览</h2>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-text-primary">
|
||||
{isReadonly ? '查看笔记' : '确认预览'}
|
||||
</h2>
|
||||
{taskId && <DeleteTaskButton taskId={taskId} />}
|
||||
</div>
|
||||
|
||||
{/* C1: 拉取失败给重新加载入口,避免永久灰死 */}
|
||||
{hydrateError && (
|
||||
<div className="mb-6 rounded-lg bg-yellow-50 border border-yellow-200 p-4" role="alert">
|
||||
<p className="text-sm text-yellow-700">加载选中内容失败。</p>
|
||||
<button
|
||||
onClick={() => taskId && loadTask(taskId)}
|
||||
className="mt-2 text-sm bg-brand-orange text-white rounded-lg px-4 py-1.5 hover:bg-orange-600"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{taskMeta?.status === 'failed' && (
|
||||
<div className="mb-6 rounded-lg bg-red-50 border border-red-200 p-4" role="alert">
|
||||
<p className="text-sm font-semibold text-red-600 mb-1">⚠ 本任务生成失败</p>
|
||||
<p className="text-sm text-red-600 whitespace-pre-wrap">
|
||||
{taskMeta.failReason || '生成过程中出错,未产出可用内容。可点下方「重新生成」重试。'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleRegenerate}
|
||||
disabled={regenerating}
|
||||
className="mt-3 text-sm bg-brand-orange text-white rounded-lg px-4 py-2 hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
{regenerating ? '重新生成中…' : '重新生成'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* 已选文案 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-secondary mb-3">已选文案</h3>
|
||||
{selectedTexts.length === 0 ? (
|
||||
{hydrating ? (
|
||||
<p className="text-text-tertiary text-sm">加载中…</p>
|
||||
) : selectedTexts.length === 0 ? (
|
||||
<p className="text-text-tertiary text-sm">暂无选中文案</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{selectedTexts.map((c) => (
|
||||
<div key={c.candidate_id}
|
||||
className="border border-border-default rounded-xl p-4 bg-surface-primary">
|
||||
onClick={isReadonly ? () => setZoomText(c) : undefined}
|
||||
className={`border border-border-default rounded-xl p-4 bg-surface-primary ${isReadonly ? 'cursor-pointer hover:border-brand-orange' : ''}`}>
|
||||
<span className="text-xs text-brand-orange font-medium">{c.angle_label}</span>
|
||||
<p className="text-sm text-text-primary mt-1 whitespace-pre-wrap line-clamp-6">
|
||||
<p className={`text-sm text-text-primary mt-1 whitespace-pre-wrap ${isReadonly ? '' : 'line-clamp-6'}`}>
|
||||
{c.content}
|
||||
</p>
|
||||
<span className="text-xs text-text-tertiary mt-1 block">总分 {c.score.total}</span>
|
||||
<span className="text-xs text-text-tertiary mt-1 block">
|
||||
总分 {c.score.total}{isReadonly ? ' · 点击看全文' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -74,18 +186,55 @@ export default function ConfirmPage() {
|
||||
</div>
|
||||
|
||||
{/* 已选图 */}
|
||||
<ConfirmPreviewImages images={selectedImages} />
|
||||
<ConfirmPreviewImages images={selectedImages} onZoom={isReadonly ? setZoomImage : undefined} />
|
||||
</div>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<ConfirmActionBar
|
||||
submitting={submitting}
|
||||
regenerating={regenerating}
|
||||
canSubmit={selectedTextIds.length > 0}
|
||||
onSubmit={handleSubmitReview}
|
||||
onRegenerate={handleRegenerate}
|
||||
/>
|
||||
{/* 提审被拒:显式红框,不再静默吞错 */}
|
||||
{submitError && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3" role="alert">
|
||||
<p className="text-sm text-red-600">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 飞轮节点反馈:提交成功后短暂绿芽提示 */}
|
||||
{flywheelMsg && (
|
||||
<div role="status" aria-live="polite"
|
||||
className="mb-4 rounded-lg bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark">
|
||||
{flywheelMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作栏:只读态(已通过/已打回)不提交审核,只给只读说明;否则正常确认操作 */}
|
||||
{isReadonly ? (
|
||||
<div className="mt-6 rounded-lg bg-gray-50 border border-border-default px-4 py-3 text-sm text-text-secondary">
|
||||
{taskMeta?.status === 'approved'
|
||||
? '本笔记已通过审核,当前为查看模式。点击文案看全文、点击图片放大查看。'
|
||||
: '本笔记已被打回,当前为查看模式。点击文案看全文、点击图片放大查看。'}
|
||||
</div>
|
||||
) : (
|
||||
<ConfirmActionBar
|
||||
submitting={submitting}
|
||||
regenerating={regenerating}
|
||||
hydrating={hydrating}
|
||||
canSubmit={selectedTextIds.length > 0 && selectedImageIds.length > 0}
|
||||
onSubmit={handleSubmitReview}
|
||||
onRegenerate={handleRegenerate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 只读查看:文案全文 / 图片放大 */}
|
||||
<TextLightbox
|
||||
text={zoomText?.content ?? null}
|
||||
label={zoomText?.angle_label}
|
||||
score={zoomText?.score.total}
|
||||
onClose={() => setZoomText(null)}
|
||||
/>
|
||||
<ImageLightbox
|
||||
url={zoomImage?.url ?? null}
|
||||
caption={zoomImage?.role}
|
||||
onClose={() => setZoomImage(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
'use client';
|
||||
// 屏4 挑图:0/N异步电影 + 按A/B/C分组选图 + 点图放大 + 单批重试 + 能离开
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { ProgressBar } from '@/components/layout/ProgressBar';
|
||||
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
|
||||
import { ImageProgressCardSkeleton } from '@/components/tasks/ImageProgressCard';
|
||||
import { ImageProgressHeader } from '@/components/tasks/ImageProgressHeader';
|
||||
import { DeleteTaskButton } from '@/components/tasks/DeleteTaskButton';
|
||||
import { ImageActionBar } from '@/components/tasks/ImageActionBar';
|
||||
import { ImageLightbox } from '@/components/tasks/ImageLightbox';
|
||||
import { ImageStrategyGroup, ROLE_LABEL } from '@/components/tasks/ImageStrategyGroup';
|
||||
import { RegenControls, RegenTarget } from '@/components/tasks/RegenControls';
|
||||
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
|
||||
import { StageProgress } from '@/components/tasks/StageProgress';
|
||||
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
|
||||
import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner';
|
||||
import { useSse } from '@/hooks/useSse';
|
||||
@@ -24,11 +26,14 @@ export default function ImageSelectionPage() {
|
||||
const taskId = params?.id ? Number(params.id) : null;
|
||||
const router = useRouter();
|
||||
|
||||
const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected } = useGenerationStore();
|
||||
const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected, removeImageCandidate } = useGenerationStore();
|
||||
const { selectedImageIds, toggleImageSelection } = useTaskStore();
|
||||
const { connectionStatus, reconnectAttempt } = useSse(taskId);
|
||||
const [zoom, setZoom] = useState<ImageCandidate | null>(null);
|
||||
const [regenTarget, setRegenTarget] = useState<RegenTarget | null>(null);
|
||||
const [regenPending, setRegenPending] = useState<Record<string, { label: string; minId: number }>>({});
|
||||
const [regenNotice, setRegenNotice] = useState<string | null>(null);
|
||||
const [justSavedFlywheel, setJustSavedFlywheel] = useState(false);
|
||||
|
||||
const pendingCount = imageTotal - imageDone;
|
||||
const allDone = imageTotal > 0 && pendingCount === 0;
|
||||
@@ -45,6 +50,29 @@ export default function ImageSelectionPage() {
|
||||
const groups: Array<['A' | 'B' | 'C', ImageCandidate[]]> = (['A', 'B', 'C'] as const).map(
|
||||
(s) => [s, latestByRole(imageCandidates.filter((c) => c.strategy === s))]
|
||||
);
|
||||
const regeneratingKeys = useMemo(() => new Set(Object.keys(regenPending)), [regenPending]);
|
||||
|
||||
useEffect(() => {
|
||||
setRegenPending((pending) => {
|
||||
let changed = false;
|
||||
const next = { ...pending };
|
||||
for (const [key, meta] of Object.entries(pending)) {
|
||||
const [strategy, role] = key.split(':');
|
||||
const finished = imageCandidates.some((c) =>
|
||||
c.strategy === strategy &&
|
||||
(role === '*' || c.role === role) &&
|
||||
c.candidate_id > meta.minId &&
|
||||
c.is_regen
|
||||
);
|
||||
if (finished) {
|
||||
delete next[key];
|
||||
changed = true;
|
||||
setRegenNotice(`${meta.label} 已重生完成,已展示最新图片`);
|
||||
}
|
||||
}
|
||||
return changed ? next : pending;
|
||||
});
|
||||
}, [imageCandidates]);
|
||||
|
||||
async function handleRetry(_batchRole: string) {
|
||||
// 一期 regenerate = 整体重生(R2 已扩展按角色单批重生,见 handleRegen)
|
||||
@@ -55,10 +83,23 @@ export default function ImageSelectionPage() {
|
||||
// R2 单张/单套重生 + 可选人工提示词
|
||||
async function handleRegen(target: RegenTarget, customPrompt: string) {
|
||||
if (!taskId) return;
|
||||
const key = `${target.strategy}:${target.role ?? '*'}`;
|
||||
if (regenPending[key]) return;
|
||||
const minId = imageCandidates.reduce((max, c) => Math.max(max, c.candidate_id), 0);
|
||||
setRegenPending((prev) => ({ ...prev, [key]: { label: target.label, minId } }));
|
||||
setRegenNotice(`${target.label} 已提交重生,生成完成后会自动替换为最新图片`);
|
||||
await api.post(`/api/v1/tasks/${taskId}/regenerate`, {
|
||||
strategy: target.strategy,
|
||||
role: target.role,
|
||||
custom_prompt: customPrompt || undefined,
|
||||
}).catch((err) => {
|
||||
setRegenPending((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setRegenNotice(err?.message || '重生提交失败,请稍后重试');
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,6 +107,19 @@ export default function ImageSelectionPage() {
|
||||
setRegenTarget({ strategy: strategy as 'A' | 'B' | 'C', role, label });
|
||||
}
|
||||
|
||||
async function handleDeleteImage(candidateId: number) {
|
||||
if (!taskId) return;
|
||||
await api.delete(`/api/v1/tasks/${taskId}/image-candidates/${candidateId}`);
|
||||
removeImageCandidate(candidateId);
|
||||
}
|
||||
|
||||
// 选图即时飞轮反馈:点选后绿芽提示4秒
|
||||
function handleToggleImage(candidateId: number) {
|
||||
toggleImageSelection(candidateId);
|
||||
setJustSavedFlywheel(true);
|
||||
setTimeout(() => setJustSavedFlywheel(false), 4000);
|
||||
}
|
||||
|
||||
async function handleProceedToConfirm() {
|
||||
if (!taskId || selectedImageIds.length === 0) return;
|
||||
for (const candidateId of selectedImageIds) {
|
||||
@@ -82,11 +136,36 @@ export default function ImageSelectionPage() {
|
||||
<FlywheelBannerFromSse
|
||||
recentPreference={flywheelInjected.recentPreference}
|
||||
rejectReasons={flywheelInjected.rejectReasons}
|
||||
signalCount={flywheelInjected.signalCount}
|
||||
/>
|
||||
)}
|
||||
{justSavedFlywheel && (
|
||||
<p role="status" aria-live="polite"
|
||||
className="px-4 py-2 text-sm text-brand-green-dark bg-brand-green-light border border-brand-green/30">
|
||||
🌿 已记录你的选择偏好
|
||||
</p>
|
||||
)}
|
||||
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
|
||||
<div className="p-6 flex-1 overflow-auto">
|
||||
{taskId && (
|
||||
<div className="mb-4 flex justify-end">
|
||||
<DeleteTaskButton taskId={taskId} />
|
||||
</div>
|
||||
)}
|
||||
<FatalErrorBanner />
|
||||
{regenNotice && (
|
||||
<div className="mb-4 flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-700">
|
||||
<span>{regenNotice}</span>
|
||||
<button
|
||||
onClick={() => setRegenNotice(null)}
|
||||
className="ml-4 text-xs text-amber-700 underline"
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* #4 阶段进度:配图全程阶段(分析→文案→配图→完成) */}
|
||||
{!allDone && <StageProgress scope="image" />}
|
||||
<ImageProgressHeader imageTotal={imageTotal} imageDone={imageDone} pendingCount={pendingCount} />
|
||||
{imageCandidates.length === 0 ? (
|
||||
<div className="grid grid-cols-3 gap-3 xl:grid-cols-6">
|
||||
@@ -100,9 +179,11 @@ export default function ImageSelectionPage() {
|
||||
strategy={s}
|
||||
candidates={list}
|
||||
selectedImageIds={selectedImageIds}
|
||||
onToggle={toggleImageSelection}
|
||||
onToggle={handleToggleImage}
|
||||
onZoom={setZoom}
|
||||
onRegen={openRegen}
|
||||
regeneratingKeys={regeneratingKeys}
|
||||
onDelete={handleDeleteImage}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
@@ -110,13 +191,20 @@ export default function ImageSelectionPage() {
|
||||
{/* 失败批次提示 */}
|
||||
{failedBatches.length > 0 && allDone && (
|
||||
<div role="alert" className="mt-4 bg-red-50 border border-status-error rounded-xl px-4 py-3 text-sm text-status-error">
|
||||
有 {failedBatches.length} 批没出来。
|
||||
{failedBatches.filter((b) => b.retryable).map((b) => (
|
||||
<button key={b.batch} onClick={() => handleRetry(b.batch)}
|
||||
className="ml-2 underline" aria-label={`重试${ROLE_LABEL[b.batch] || b.batch}`}>
|
||||
⟳ 重试{ROLE_LABEL[b.batch] || b.batch}
|
||||
</button>
|
||||
))}
|
||||
<p className="font-medium">有 {failedBatches.length} 批没出来。</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{failedBatches.map((b) => (
|
||||
<li key={b.batch} className="flex flex-wrap items-center gap-2">
|
||||
<span>{ROLE_LABEL[b.batch] || b.batch}:{b.reason || '中转站暂时无响应'}</span>
|
||||
{b.retryable && (
|
||||
<button onClick={() => handleRetry(b.batch)}
|
||||
className="underline" aria-label={`重试${ROLE_LABEL[b.batch] || b.batch}`}>
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{imageCandidates.length > 0 && (
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
'use client';
|
||||
// 屏3 挑文案:SSE实时+五维分+多选+飞轮隐形
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { ProgressBar } from '@/components/layout/ProgressBar';
|
||||
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
|
||||
import { DeleteTaskButton } from '@/components/tasks/DeleteTaskButton';
|
||||
import { FlywheelBanner, FlywheelBannerFromSse } from '@/components/FlywheelBanner';
|
||||
import { TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard';
|
||||
import { TextStrategyGroup, groupByStrategy } from '@/components/tasks/TextStrategyGroup';
|
||||
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
|
||||
import { StageProgress } from '@/components/tasks/StageProgress';
|
||||
import { TextOutcomeBanner } from '@/components/tasks/TextOutcomeBanner';
|
||||
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
|
||||
import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner';
|
||||
import { useSse } from '@/hooks/useSse';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { useTaskStore } from '@/stores/taskStore';
|
||||
import { usePreferenceStore } from '@/stores/preferenceStore';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function TextSelectionPage() {
|
||||
@@ -19,14 +23,32 @@ export default function TextSelectionPage() {
|
||||
const taskId = params?.id ? Number(params.id) : null;
|
||||
const router = useRouter();
|
||||
|
||||
const { textCandidates, textDone, textTotal, flywheelInjected, patchTextCandidateContent } = useGenerationStore();
|
||||
const { stage, textCandidates, textDone, textTotal, flywheelInjected, patchTextCandidateContent, removeTextCandidate } = useGenerationStore();
|
||||
const { selectedTextIds, toggleTextSelection, setCurrentTask } = useTaskStore();
|
||||
const { context, fetchContext } = usePreferenceStore();
|
||||
const { connectionStatus, reconnectAttempt } = useSse(taskId);
|
||||
const [proceedError, setProceedError] = useState('');
|
||||
const [track, setTrack] = useState<'ai' | 'import' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskId) setCurrentTask(taskId, 'generating');
|
||||
}, [taskId]);
|
||||
|
||||
// 历史回填:无 SSE 活跃飞轮数据时,拉 task 维度 context 兜底展示
|
||||
useEffect(() => {
|
||||
if (!taskId || flywheelInjected) return;
|
||||
fetchContext(taskId);
|
||||
}, [taskId, flywheelInjected]);
|
||||
|
||||
// 拉取 track:导入轨「去生图」要先触发生图(reuse_text)再跳页,AI轨直接跳页。
|
||||
// track=null 表示未拉到,按钮禁用,避免竞态误把导入轨当 AI 轨跳过生图触发。
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
api.get<{ track?: 'ai' | 'import' }>(`/api/v1/tasks/${taskId}`)
|
||||
.then((t) => setTrack(t.track === 'import' ? 'import' : 'ai'))
|
||||
.catch(() => setTrack('ai'));
|
||||
}, [taskId]);
|
||||
|
||||
async function handleSaveEdit(candidateId: number, content: string) {
|
||||
if (!taskId) return;
|
||||
// D1 改稿=飞轮最强信号 text_edit,后端回写 + record_signal
|
||||
@@ -36,43 +58,80 @@ export default function TextSelectionPage() {
|
||||
patchTextCandidateContent(candidateId, content);
|
||||
}
|
||||
|
||||
async function handleDeleteText(candidateId: number) {
|
||||
if (!taskId) return;
|
||||
await api.delete(`/api/v1/tasks/${taskId}/text-candidates/${candidateId}`);
|
||||
removeTextCandidate(candidateId);
|
||||
}
|
||||
|
||||
async function handleProceedToImages() {
|
||||
if (!taskId || selectedTextIds.length === 0) return;
|
||||
setProceedError('');
|
||||
const failed: number[] = [];
|
||||
for (const candidateId of selectedTextIds) {
|
||||
await api.post(`/api/v1/tasks/${taskId}/text-candidates/select`, { candidate_id: candidateId });
|
||||
try {
|
||||
await api.post(`/api/v1/tasks/${taskId}/text-candidates/select`, { candidate_id: candidateId });
|
||||
} catch {
|
||||
// 不再静默吞错:残留/失效id会被后端拒(404),要让用户知道而非"以为都生效了"
|
||||
failed.push(candidateId);
|
||||
}
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
setProceedError(`有 ${failed.length} 条文案选中失败(可能已失效),请重新选择后再试。`);
|
||||
return;
|
||||
}
|
||||
// 导入轨:选完文案点「去生图」→ 触发只生图(复用导入文案),再跳生图页看进度。
|
||||
// AI轨:生图已在建任务时随文案一并跑完,直接跳页即可。
|
||||
if (track === 'import') {
|
||||
try {
|
||||
await api.post(`/api/v1/tasks/${taskId}/generate-images`, {});
|
||||
} catch {
|
||||
setProceedError('触发生图失败,请稍后重试。');
|
||||
return;
|
||||
}
|
||||
}
|
||||
router.push(`/tasks/${taskId}/images`);
|
||||
}
|
||||
|
||||
const isGenerating = textDone < textTotal || textTotal === 0;
|
||||
// task_done(stage=done)/failed 后生成已结束——绝不再显示"生成中"骨架屏装在跑,
|
||||
// 否则就是图17那种"完成了却永远转圈/0条还显完成"。
|
||||
const finished = stage === 'done' || stage === 'failed';
|
||||
const isGenerating = !finished && (textDone < textTotal || textTotal === 0);
|
||||
const showSkeleton = textCandidates.length === 0 && isGenerating;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ProgressBar currentStep={2} />
|
||||
<RejectReasonBanner taskId={taskId} />
|
||||
{flywheelInjected && (
|
||||
<FlywheelBannerFromSse recentPreference={flywheelInjected.recentPreference} rejectReasons={flywheelInjected.rejectReasons} />
|
||||
)}
|
||||
{flywheelInjected ? (
|
||||
<FlywheelBannerFromSse
|
||||
recentPreference={flywheelInjected.recentPreference}
|
||||
rejectReasons={flywheelInjected.rejectReasons}
|
||||
signalCount={flywheelInjected.signalCount}
|
||||
/>
|
||||
) : context ? (
|
||||
<FlywheelBanner context={context} />
|
||||
) : null}
|
||||
|
||||
<div className="p-6 flex-1 overflow-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-text-primary">
|
||||
挑文案{textTotal > 0 && <span className="text-sm font-normal text-text-secondary ml-2">已生成 {textDone}/{textTotal} 条</span>}
|
||||
</h2>
|
||||
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
|
||||
<div className="flex items-center gap-3">
|
||||
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
|
||||
{taskId && <DeleteTaskButton taskId={taskId} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任务级失败提示(B6):生成彻底失败时明确反馈,不再无限转圈 */}
|
||||
<FatalErrorBanner />
|
||||
|
||||
{/* SSE 进度提示(生成中且无内容时) */}
|
||||
{showSkeleton && (
|
||||
<div className="mb-4 bg-brand-cream border border-brand-orange/20 rounded-xl px-4 py-3 text-sm text-text-secondary"
|
||||
role="status" aria-live="polite">
|
||||
正在生成文案… 预计 15-30 秒,好了会更新,可先去忙别的
|
||||
</div>
|
||||
)}
|
||||
{/* B1 文案产出结果提示:完成但没出文案/补充中/评分不可用,明确告知真实原因 */}
|
||||
<TextOutcomeBanner />
|
||||
|
||||
{/* #3 阶段进度:替代静态"预计15-30秒",展示 分析竞品→生成文案 实时阶段 */}
|
||||
{isGenerating && <StageProgress scope="text" />}
|
||||
|
||||
{/* 文案卡:按 A/B/C 三套分组展示(让运营一眼看出三套不同角度);生成中显示骨架 */}
|
||||
{showSkeleton ? (
|
||||
@@ -88,6 +147,7 @@ export default function TextSelectionPage() {
|
||||
selectedTextIds={selectedTextIds}
|
||||
onToggle={toggleTextSelection}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onDelete={handleDeleteText}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -97,10 +157,11 @@ export default function TextSelectionPage() {
|
||||
<div className="sticky bottom-0 bg-surface-primary border-t border-border-default px-6 py-4 flex items-center justify-between mt-4">
|
||||
<span className="text-sm text-text-secondary">
|
||||
已选 <strong className="text-text-primary">{selectedTextIds.length}</strong> 条
|
||||
{proceedError && <span className="ml-3 text-red-600">{proceedError}</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleProceedToImages}
|
||||
disabled={selectedTextIds.length === 0}
|
||||
disabled={selectedTextIds.length === 0 || track === null}
|
||||
aria-label={`用选中的 ${selectedTextIds.length} 条文案去生图`}
|
||||
className="bg-brand-orange text-white rounded-lg px-6 py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ApiError } from '@/types/index';
|
||||
|
||||
export default function NewTaskPage() {
|
||||
const router = useRouter();
|
||||
const { context } = usePreferenceStore();
|
||||
const { context, fetchContextByProduct } = usePreferenceStore();
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const [benchmarks, setBenchmarks] = useState<Benchmark[]>([]);
|
||||
@@ -35,6 +35,7 @@ export default function NewTaskPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState(false); // 提升:供右侧「编辑配置」(运营/组长)触发就地编辑
|
||||
|
||||
useEffect(() => { loadProducts(); }, []);
|
||||
|
||||
@@ -47,6 +48,12 @@ export default function NewTaskPage() {
|
||||
.catch(() => setBenchmarks([]));
|
||||
}, [selectedProduct]);
|
||||
|
||||
// 选品变化:拉该产品飞轮偏好上下文,驱动顶部 FlywheelBanner
|
||||
useEffect(() => {
|
||||
if (!selectedProduct) return;
|
||||
fetchContextByProduct(selectedProduct.id);
|
||||
}, [selectedProduct?.id]);
|
||||
|
||||
async function loadProducts() {
|
||||
try {
|
||||
const data = await api.get<PaginatedResponse<Product>>('/api/v1/products');
|
||||
@@ -57,6 +64,12 @@ export default function NewTaskPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// #1 内联建品:把新产品并入列表顶部并选中(QuickProductCreate 已含上传产品图)
|
||||
function handleProductCreated(p: Product) {
|
||||
setProducts((list) => [p, ...list.filter((x) => x.id !== p.id)]);
|
||||
setSelectedProduct(p);
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
if (!selectedProduct) { setError('请选择产品'); return false; }
|
||||
if (!form.theme.trim()) { setError('请填写今天主题'); return false; }
|
||||
@@ -135,6 +148,9 @@ export default function NewTaskPage() {
|
||||
products={products}
|
||||
selectedProduct={selectedProduct}
|
||||
onSelectProduct={setSelectedProduct}
|
||||
onProductCreated={handleProductCreated}
|
||||
editingProduct={editingProduct}
|
||||
setEditingProduct={setEditingProduct}
|
||||
benchmarks={benchmarks}
|
||||
form={form}
|
||||
onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))}
|
||||
@@ -147,7 +163,7 @@ export default function NewTaskPage() {
|
||||
</div>
|
||||
|
||||
{/* 右侧配置预览面板 */}
|
||||
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} />}
|
||||
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} onEditInline={() => setEditingProduct(true)} />}
|
||||
</div>
|
||||
|
||||
<ImportTextModal
|
||||
|
||||
@@ -37,7 +37,7 @@ interface AuthGuardProps {
|
||||
export function AuthGuard({ children }: AuthGuardProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isAuthenticated, role, isLoading, fetchMe, endLoading } = useAuthStore();
|
||||
const { isAuthenticated, role, user, isLoading, fetchMe, endLoading } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
// 首屏校验:有 token 且 store 未初始化 → 拉 me;无 token → 结束 loading 让守卫放行判断
|
||||
@@ -60,11 +60,20 @@ export function AuthGuard({ children }: AuthGuardProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (user?.must_change_password && pathname !== '/change-password') {
|
||||
router.replace('/change-password');
|
||||
return;
|
||||
}
|
||||
if (!user?.must_change_password && pathname === '/change-password') {
|
||||
router.replace('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
const required = getRequiredRoles(pathname);
|
||||
if (required && role && !required.includes(role)) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [isAuthenticated, role, isLoading, pathname, router]);
|
||||
}, [isAuthenticated, role, user?.must_change_password, isLoading, pathname, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -11,26 +11,37 @@ interface FlywheelBannerProps {
|
||||
}
|
||||
|
||||
export function FlywheelBanner({ context }: FlywheelBannerProps) {
|
||||
if (!context.injected_count && !context.recent_preference) return null;
|
||||
const signalCount = context.signal_count ?? 0;
|
||||
if (!context.injected_count && !context.recent_preference && !signalCount) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark flex items-start gap-2"
|
||||
className="bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark flex flex-col gap-1"
|
||||
>
|
||||
<span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span>
|
||||
<div>
|
||||
<span className="font-medium">本次已注入:</span>
|
||||
{context.recent_preference && (
|
||||
<span>{context.recent_preference}</span>
|
||||
)}
|
||||
{context.reject_reasons?.length > 0 && (
|
||||
<span className="ml-2 text-text-secondary">
|
||||
· 打回原因:{context.reject_reasons.slice(0, 2).join(';')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{signalCount > 0 && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span>
|
||||
<span className="font-medium">飞轮已积累 {signalCount} 条偏好信号,越用越懂你</span>
|
||||
</div>
|
||||
)}
|
||||
{(context.recent_preference || (context.reject_reasons?.length ?? 0) > 0) && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span>
|
||||
<div>
|
||||
<span className="font-medium">本次已注入:</span>
|
||||
{context.recent_preference && (
|
||||
<span>{context.recent_preference}</span>
|
||||
)}
|
||||
{context.reject_reasons?.length > 0 && (
|
||||
<span className="ml-2 text-text-secondary">
|
||||
· 打回原因:{context.reject_reasons.slice(0, 2).join(';')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,24 +52,34 @@ export function FlywheelBanner({ context }: FlywheelBannerProps) {
|
||||
interface FlywheelBannerFromSseProps {
|
||||
recentPreference: string;
|
||||
rejectReasons: string[];
|
||||
signalCount?: number;
|
||||
}
|
||||
|
||||
export function FlywheelBannerFromSse({ recentPreference, rejectReasons }: FlywheelBannerFromSseProps) {
|
||||
export function FlywheelBannerFromSse({ recentPreference, rejectReasons, signalCount }: FlywheelBannerFromSseProps) {
|
||||
const count = signalCount ?? 0;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark flex items-start gap-2"
|
||||
className="bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark flex flex-col gap-1"
|
||||
>
|
||||
<span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span>
|
||||
<div>
|
||||
<span className="font-medium">本次已注入:</span>
|
||||
{recentPreference && <span>{recentPreference}</span>}
|
||||
{rejectReasons?.length > 0 && (
|
||||
<span className="ml-2 text-text-secondary">
|
||||
· 打回原因:{rejectReasons.slice(0, 2).join(';')}
|
||||
</span>
|
||||
)}
|
||||
{count > 0 && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span>
|
||||
<span className="font-medium">飞轮已积累 {count} 条偏好信号,越用越懂你</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2">
|
||||
<span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span>
|
||||
<div>
|
||||
<span className="font-medium">本次已注入:</span>
|
||||
{recentPreference && <span>{recentPreference}</span>}
|
||||
{rejectReasons?.length > 0 && (
|
||||
<span className="ml-2 text-text-secondary">
|
||||
· 打回原因:{rejectReasons.slice(0, 2).join(';')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
158
frontend/src/components/common/StageLoadingPanel.tsx
Normal file
158
frontend/src/components/common/StageLoadingPanel.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
/**
|
||||
* StageLoadingPanel — 通用多阶段加载面板(A1/A2/A3 复用)
|
||||
* - 阶段列表:待完成灰 / 当前橙色 pulse 点 / 已完成绿勾
|
||||
* - 线性进度条(公式可配)+ ETA 胶囊
|
||||
* - 图片骨架屏 shimmer(可选)
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface StageItem {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
stages: StageItem[];
|
||||
/** 当前激活阶段索引(0-based);-1 = 未开始;>= stages.length = 全完成 */
|
||||
activeIdx: number;
|
||||
/** ETA 目标秒数(用于线性估算) */
|
||||
targetSec: number;
|
||||
/** 任务启动时间戳(ms),用于推算进度和 ETA */
|
||||
startedAt: number | null;
|
||||
/** 进度条已完成比例(0-100),若提供则直接用(不再用时间公式) */
|
||||
realPct?: number;
|
||||
/** 标题(左上) */
|
||||
title: string;
|
||||
/** 图片骨架数量(0 = 不显示骨架屏) */
|
||||
skeletonCount?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 线性估算进度公式:min(96, base + elapsed * rate)
|
||||
function calcPct(elapsed: number, targetSec: number): number {
|
||||
const rate = 96 / (targetSec * 0.85); // 85% 时间跑到 96%
|
||||
return Math.min(96, Math.round(elapsed * rate));
|
||||
}
|
||||
|
||||
// 进度条与步骤耦合:时间驱动平滑动画,但被「当前步骤」的区间钳住——
|
||||
// 步骤不往前走,进度条就不会冲过这一步的上限边界。
|
||||
// 杜绝倩倩姐反馈的"进度条快满了、下面步骤还卡在第一步、看起来像卡死"。
|
||||
// floor=当前步起点(真实进度,步骤前进时立刻顶上来);ceil-gap=当前步上限(留余量表示"未完成")。
|
||||
function stepBoundedPct(
|
||||
elapsed: number, targetSec: number, activeIdx: number, total: number,
|
||||
): number {
|
||||
const timed = calcPct(elapsed, targetSec);
|
||||
if (total <= 0) return timed;
|
||||
const idx = Math.max(0, activeIdx); // -1(未开始)按第0步对待,条停在最左
|
||||
const floor = (idx / total) * 100;
|
||||
const ceil = ((idx + 1) / total) * 100;
|
||||
const gap = Math.min(3, ceil - floor) ; // 距上限留点空隙,肉眼可见"这步还在跑"
|
||||
return Math.round(Math.min(ceil - gap, Math.max(floor, timed)));
|
||||
}
|
||||
|
||||
function formatEta(remainSec: number): string {
|
||||
if (remainSec <= 0) return '即将完成';
|
||||
if (remainSec < 60) return `约 ${Math.ceil(remainSec)} 秒`;
|
||||
return `约 ${Math.ceil(remainSec / 60)} 分钟`;
|
||||
}
|
||||
|
||||
export function StageLoadingPanel({
|
||||
stages, activeIdx, targetSec, startedAt, realPct,
|
||||
title, skeletonCount = 0, className,
|
||||
}: Props) {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!startedAt) return;
|
||||
const tick = () => setElapsed(Math.floor((Date.now() - startedAt) / 1000));
|
||||
tick();
|
||||
timerRef.current = setInterval(tick, 1000);
|
||||
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
||||
}, [startedAt]);
|
||||
|
||||
const pct = realPct !== undefined
|
||||
? realPct
|
||||
: (startedAt ? stepBoundedPct(elapsed, targetSec, activeIdx, stages.length) : 0);
|
||||
const remainSec = Math.max(0, targetSec - elapsed);
|
||||
|
||||
// 超时提示
|
||||
let hint = '';
|
||||
if (elapsed > 120) hint = '耗时较长,可降低生成数量后重试';
|
||||
else if (elapsed > 60) hint = 'AI 仍在生成,请保持页面打开';
|
||||
|
||||
const allDone = activeIdx >= stages.length;
|
||||
|
||||
return (
|
||||
<div className={clsx('rounded-xl border border-brand-orange/20 bg-brand-cream p-4', className)}>
|
||||
{/* 标题行 + ETA 胶囊 */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-semibold text-text-primary text-sm">{title}</span>
|
||||
{!allDone && startedAt && (
|
||||
<span className="text-xs bg-orange-100 text-brand-orange px-2.5 py-0.5 rounded-full font-medium">
|
||||
{hint || formatEta(remainSec)}
|
||||
</span>
|
||||
)}
|
||||
{allDone && (
|
||||
<span className="text-xs bg-green-100 text-green-600 px-2.5 py-0.5 rounded-full font-medium">
|
||||
完成
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div
|
||||
className="mb-3 h-1.5 bg-surface-tertiary rounded-full overflow-hidden"
|
||||
role="progressbar" aria-valuenow={allDone ? 100 : pct} aria-valuemin={0} aria-valuemax={100}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'h-full rounded-full transition-all duration-1000',
|
||||
allDone ? 'bg-green-500' : 'bg-brand-orange',
|
||||
)}
|
||||
style={{ width: `${allDone ? 100 : pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 阶段列表 */}
|
||||
<ol className="space-y-1.5">
|
||||
{stages.map((s, i) => {
|
||||
const isDone = i < activeIdx || allDone;
|
||||
const isActive = i === activeIdx && !allDone;
|
||||
return (
|
||||
<li key={i} className="flex items-center gap-2 text-sm">
|
||||
{/* 状态指示器 */}
|
||||
{isDone ? (
|
||||
<span className="w-4 h-4 flex-shrink-0 rounded-full bg-green-500 flex items-center justify-center text-white text-[10px]">✓</span>
|
||||
) : isActive ? (
|
||||
<span className="w-4 h-4 flex-shrink-0 rounded-full bg-brand-orange animate-pulse" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="w-4 h-4 flex-shrink-0 rounded-full bg-gray-200" aria-hidden="true" />
|
||||
)}
|
||||
<span className={clsx(
|
||||
isDone ? 'text-text-secondary' :
|
||||
isActive ? 'text-brand-orange font-medium' : 'text-text-tertiary',
|
||||
)}>
|
||||
{s.label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* 骨架屏占位(图片未到时展示 shimmer) */}
|
||||
{skeletonCount > 0 && !allDone && (
|
||||
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||
{Array.from({ length: skeletonCount }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-[3/4] rounded-lg bg-gradient-to-r from-gray-100 via-gray-200 to-gray-100 bg-[length:200%_100%] animate-[shimmer_1.5s_infinite]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
// ApiKeysTab — API Key 录入(只录不显余额,CLAUDE.md红线)
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { ApiKey, CreateApiKeyRequest, PaginatedResponse, ApiError } from '@/types/index';
|
||||
import { getErrorAction } from '@/types/errors';
|
||||
@@ -10,7 +10,13 @@ export function ApiKeysTab() {
|
||||
const [form, setForm] = useState<CreateApiKeyRequest>({ provider: 'openai', api_key: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const keyInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 当前选中 provider 是否已录过 → 决定是「保存」还是「更新覆盖」
|
||||
const isOverwrite = keys.some((k) => k.provider === form.provider);
|
||||
|
||||
useEffect(() => { loadKeys(); }, []);
|
||||
|
||||
@@ -22,10 +28,18 @@ export function ApiKeysTab() {
|
||||
} catch { /* silent */ } finally { setLoading(false); }
|
||||
}
|
||||
|
||||
// 点列表「修改」:把该 provider 填回表单、清空 key 待重录、聚焦输入框(覆盖式改 key)
|
||||
function handleEdit(provider: string) {
|
||||
setForm({ provider, api_key: '' });
|
||||
setError('');
|
||||
setSuccess('');
|
||||
keyInputRef.current?.focus();
|
||||
}
|
||||
|
||||
async function handleAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.api_key.trim()) { setError('请填写 API Key'); return; }
|
||||
setSaving(true); setError('');
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
await api.post('/api/v1/api-keys', form);
|
||||
setForm({ provider: 'openai', api_key: '' });
|
||||
@@ -36,6 +50,18 @@ export function ApiKeysTab() {
|
||||
} finally { setSaving(false); }
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
if (!form.api_key.trim()) { setError('请填写 API Key'); return; }
|
||||
setTesting(true); setError(''); setSuccess('');
|
||||
try {
|
||||
await api.post('/api/v1/api-keys/test', form);
|
||||
setSuccess('测试通过,可以保存');
|
||||
} catch (err) {
|
||||
const apiErr = err as ApiError;
|
||||
setError(apiErr?.message || '测试失败,请检查 Key');
|
||||
} finally { setTesting(false); }
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!confirm('确认删除此 Key?')) return;
|
||||
try {
|
||||
@@ -47,33 +73,41 @@ export function ApiKeysTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-text-secondary bg-brand-cream-dark px-4 py-3 rounded-lg">
|
||||
API Key 由各人自录,余额和用量请到中转站后台查看。
|
||||
API Key 由各人自录,录一次即记住,下次登录自动带出。要换 key 点对应通道的「修改」直接覆盖。余额和用量请到中转站后台查看。
|
||||
</p>
|
||||
<form onSubmit={handleAdd} className="flex gap-3 items-end">
|
||||
<div>
|
||||
<label htmlFor="provider" className="block text-sm font-medium text-text-primary mb-1">Provider</label>
|
||||
<select id="provider" value={form.provider}
|
||||
onChange={(e) => setForm((f) => ({ ...f, provider: e.target.value }))}
|
||||
onChange={(e) => { setError(''); setSuccess(''); setForm((f) => ({ ...f, provider: e.target.value })); }}
|
||||
className="border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange">
|
||||
<option value="openai">OpenAI (gpt-image-2)</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="openai">主通道 apiports(生图+生文,必填)</option>
|
||||
<option value="codeproxy">备用通道 codeproxy(apiports 繁忙时自动顶上,选填)</option>
|
||||
<option value="gemini">Gemini(生图备选,选填)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="api-key-input" className="block text-sm font-medium text-text-primary mb-1">API Key</label>
|
||||
<input id="api-key-input" type="password" value={form.api_key}
|
||||
onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))}
|
||||
<label htmlFor="api-key-input" className="block text-sm font-medium text-text-primary mb-1">
|
||||
API Key{isOverwrite && <span className="text-brand-orange ml-1">(已录入,重新填写将覆盖)</span>}
|
||||
</label>
|
||||
<input id="api-key-input" ref={keyInputRef} type="password" value={form.api_key}
|
||||
onChange={(e) => { setError(''); setSuccess(''); setForm((f) => ({ ...f, api_key: e.target.value })); }}
|
||||
placeholder="sk-..."
|
||||
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
|
||||
aria-label="输入 API Key"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={saving} aria-label="保存 API Key"
|
||||
className="bg-brand-orange text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-orange-600 disabled:opacity-50">
|
||||
{saving ? '保存中…' : '保存'}
|
||||
<button type="button" onClick={handleTest} disabled={testing || saving}
|
||||
className="border border-border-default text-text-secondary rounded-lg px-4 py-2 text-sm font-medium hover:border-brand-orange hover:text-brand-orange disabled:opacity-50 whitespace-nowrap">
|
||||
{testing ? '测试中…' : '测试'}
|
||||
</button>
|
||||
<button type="submit" disabled={saving || testing} aria-label={isOverwrite ? '更新 API Key' : '保存 API Key'}
|
||||
className="bg-brand-orange text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-orange-600 disabled:opacity-50 whitespace-nowrap">
|
||||
{saving ? '保存中…' : isOverwrite ? '更新覆盖' : '保存'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p role="alert" className="text-status-error text-sm">{error}</p>}
|
||||
{success && <p role="status" className="text-green-700 text-sm">{success}</p>}
|
||||
{loading ? (
|
||||
<div className="text-text-secondary text-sm">加载中…</div>
|
||||
) : (
|
||||
@@ -84,8 +118,12 @@ export function ApiKeysTab() {
|
||||
<span className="font-medium">{k.provider}</span>
|
||||
<span className="text-text-secondary ml-2">…{k.key_last4}</span>
|
||||
</span>
|
||||
<button onClick={() => handleDelete(k.id)} aria-label={`删除 ${k.provider} Key`}
|
||||
className="text-status-error text-xs hover:underline">删除</button>
|
||||
<span className="flex items-center gap-3">
|
||||
<button onClick={() => handleEdit(k.provider)} aria-label={`修改 ${k.provider} Key`}
|
||||
className="text-brand-orange text-xs hover:underline">修改</button>
|
||||
<button onClick={() => handleDelete(k.id)} aria-label={`删除 ${k.provider} Key`}
|
||||
className="text-status-error text-xs hover:underline">删除</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{keys.length === 0 && (
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { CreateBenchmarkRequest } from '@/types/index';
|
||||
|
||||
interface BenchmarkFormProps {
|
||||
productId: number;
|
||||
@@ -13,18 +12,33 @@ interface BenchmarkFormProps {
|
||||
}
|
||||
|
||||
export function BenchmarkForm({ productId, onSaved, onCancel }: BenchmarkFormProps) {
|
||||
const [form, setForm] = useState<CreateBenchmarkRequest>({
|
||||
screenshot_url: '', highlights: '', link_url: null,
|
||||
});
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [highlights, setHighlights] = useState('');
|
||||
const [screenshot, setScreenshot] = useState<File | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.screenshot_url || !form.highlights) return;
|
||||
if (!screenshot && !linkUrl.trim() && !highlights.trim()) {
|
||||
setError('请至少上传截图、填写原始链接或填写亮点');
|
||||
return;
|
||||
}
|
||||
if (screenshot && screenshot.size > 10 * 1024 * 1024) {
|
||||
setError('截图超过 10 MB');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/api/v1/products/${productId}/benchmarks`, form);
|
||||
const formData = new FormData();
|
||||
if (screenshot) formData.append('screenshot', screenshot);
|
||||
formData.append('highlights', highlights.trim());
|
||||
formData.append('link_url', linkUrl.trim());
|
||||
await api.postForm(`/api/v1/products/${productId}/benchmarks/upload`, formData);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError((err as Error)?.message || '保存失败,请重试');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -35,22 +49,32 @@ export function BenchmarkForm({ productId, onSaved, onCancel }: BenchmarkFormPro
|
||||
className="border border-border-default rounded-xl p-6 bg-surface-primary space-y-4">
|
||||
<h3 className="font-medium text-text-primary">上传标杆笔记</h3>
|
||||
<div>
|
||||
<label htmlFor="bm-url" className="block text-sm font-medium mb-1">截图 URL</label>
|
||||
<input id="bm-url" value={form.screenshot_url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, screenshot_url: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
<label htmlFor="bm-link" className="block text-sm font-medium mb-1">原始链接</label>
|
||||
<input id="bm-link" value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
placeholder="https://www.xiaohongshu.com/..."
|
||||
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
|
||||
required aria-required="true"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-tertiary">用于保留来源,不再当截图读取,长链接也可以保存。</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="bm-highlights" className="block text-sm font-medium mb-1">亮点(手填)</label>
|
||||
<textarea id="bm-highlights" value={form.highlights} rows={3}
|
||||
onChange={(e) => setForm((f) => ({ ...f, highlights: e.target.value }))}
|
||||
<label htmlFor="bm-screenshot" className="block text-sm font-medium mb-1">截图文件</label>
|
||||
<input id="bm-screenshot" type="file" accept="image/jpeg,image/png,image/webp"
|
||||
onChange={(e) => setScreenshot(e.target.files?.[0] ?? null)}
|
||||
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
|
||||
/>
|
||||
{screenshot && (
|
||||
<p className="mt-1 text-xs text-text-secondary truncate">已选择:{screenshot.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="bm-highlights" className="block text-sm font-medium mb-1">亮点(手填,可选)</label>
|
||||
<textarea id="bm-highlights" value={highlights} rows={3}
|
||||
onChange={(e) => setHighlights(e.target.value)}
|
||||
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange resize-none"
|
||||
required aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-status-error">{error}</p>}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button type="button" onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary">
|
||||
|
||||
@@ -8,6 +8,12 @@ import { Benchmark, Product } from '@/types/index';
|
||||
import { PaginatedResponse } from '@/types/index';
|
||||
import { BenchmarkForm } from './BenchmarkForm';
|
||||
|
||||
function toUploadSrc(path: string | null) {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
||||
return `/uploads/${path.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '')}`;
|
||||
}
|
||||
|
||||
export function BenchmarksTab() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
|
||||
@@ -67,10 +73,38 @@ export function BenchmarksTab() {
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{benchmarks.map((b) => (
|
||||
<div key={b.id} className="border border-border-default rounded-xl overflow-hidden bg-surface-primary">
|
||||
<img src={b.screenshot_url} alt="标杆笔记截图"
|
||||
className="w-full h-32 object-cover bg-surface-tertiary" />
|
||||
{b.screenshot_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={toUploadSrc(b.screenshot_url)} alt="标杆笔记截图"
|
||||
className="w-full h-32 object-cover bg-surface-tertiary" />
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center bg-surface-tertiary text-xs text-text-tertiary">
|
||||
未上传截图
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3">
|
||||
<p className="text-xs text-text-secondary line-clamp-2">{b.highlights}</p>
|
||||
<div className="mb-2 flex items-center gap-2 text-[11px]">
|
||||
<span className={
|
||||
b.analyze_status === 'done'
|
||||
? 'rounded bg-green-50 px-2 py-0.5 text-status-success'
|
||||
: b.analyze_status === 'failed'
|
||||
? 'rounded bg-red-50 px-2 py-0.5 text-status-error'
|
||||
: 'rounded bg-yellow-50 px-2 py-0.5 text-status-warning'
|
||||
}>
|
||||
{b.analyze_status === 'done' ? '分析完成' : b.analyze_status === 'failed' ? '分析失败' : '待分析'}
|
||||
</span>
|
||||
{b.analysis_source === 'fallback' && (
|
||||
<span className="rounded bg-yellow-50 px-2 py-0.5 text-status-warning">兜底结果</span>
|
||||
)}
|
||||
</div>
|
||||
{b.analysis_warning && (
|
||||
<p className="mb-2 rounded bg-yellow-50 px-2 py-1 text-[11px] text-status-warning">
|
||||
{b.analysis_warning}
|
||||
</p>
|
||||
)}
|
||||
{b.highlights && (
|
||||
<p className="text-xs text-text-secondary line-clamp-2">{b.highlights}</p>
|
||||
)}
|
||||
{b.link_url && (
|
||||
<a href={b.link_url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-xs text-brand-orange hover:underline mt-1 block">查看原帖</a>
|
||||
@@ -80,7 +114,7 @@ export function BenchmarksTab() {
|
||||
))}
|
||||
{benchmarks.length === 0 && (
|
||||
<p className="col-span-3 text-text-tertiary text-sm py-4 text-center">
|
||||
暂无标杆笔记,点击"传成品包"上传截图
|
||||
暂无标杆笔记,点击「传成品包」上传截图
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
88
frontend/src/components/config/CreateUserModal.tsx
Normal file
88
frontend/src/components/config/CreateUserModal.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
/**
|
||||
* CreateUserModal — 管理员新建账号弹窗
|
||||
* 用户名/邮箱/角色/初始密码;建成后该账号首登强制改密。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { ApiError } from '@/types';
|
||||
|
||||
const INPUT_CLS = 'w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange transition-colors';
|
||||
|
||||
const ROLES = [
|
||||
{ value: 'operator', label: '运营(日常出稿)' },
|
||||
{ value: 'supervisor', label: '组长(可审核)' },
|
||||
{ value: 'admin', label: '管理员(含账号管理)' },
|
||||
];
|
||||
|
||||
export function CreateUserModal({ onClose, onCreated }: {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState('operator');
|
||||
const [initPassword, setInitPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (initPassword.length < 8) {
|
||||
setError('初始密码至少 8 位');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.post('/api/v1/users', {
|
||||
username, email, role, init_password: initPassword,
|
||||
});
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
setError((err as ApiError)?.message || '创建失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="w-96 bg-surface-primary rounded-2xl shadow-lg p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-bold text-text-primary mb-4">新建账号</h3>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">用户名</label>
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} className={INPUT_CLS} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">邮箱</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className={INPUT_CLS} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">角色</label>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)} className={INPUT_CLS}>
|
||||
{ROLES.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">初始密码(至少8位)</label>
|
||||
<input type="text" value={initPassword} onChange={(e) => setInitPassword(e.target.value)}
|
||||
className={INPUT_CLS} placeholder="员工首登须改密" required />
|
||||
</div>
|
||||
{error && <div role="alert" className="text-status-error text-sm bg-red-50 px-3 py-2 rounded-lg">{error}</div>}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button type="button" onClick={onClose}
|
||||
className="flex-1 border border-border-default text-text-secondary rounded-lg py-2 text-sm hover:bg-surface-tertiary">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving}
|
||||
className="flex-1 bg-brand-orange text-white rounded-lg py-2 text-sm font-medium hover:bg-orange-600 disabled:opacity-50">
|
||||
{saving ? '创建中…' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
frontend/src/components/config/ProductFormFull.tsx
Normal file
127
frontend/src/components/config/ProductFormFull.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
/**
|
||||
* ProductFormFull — 统一产品档案表单(新建 + 编辑共用,倩倩姐2026-06-26拍板)
|
||||
* 字段:产品名(必填)/品类/品牌词/风格调性/目标人群/卖点(多条,一行一条)。
|
||||
* 这些字段全部喂进文案+生图 prompt(_text_prompt.py / storyboard.py),填得越细生成越贴合。
|
||||
* product 有值=编辑(PUT /products/{id});无值=新建(POST /products)。
|
||||
* 图片管理不在此组件(由 ProductCard / ProductEditPanel 各自的 ProductImageManager 负责)。
|
||||
* text_angles / custom_prompt 偏技术暂不暴露,编辑时原样保留不清空。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Product, CreateProductRequest } from '@/types/index';
|
||||
|
||||
export function ProductFormFull({
|
||||
product,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
product?: Product;
|
||||
onSaved: (p: Product) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const isEdit = !!product;
|
||||
const [name, setName] = useState(product?.name ?? '');
|
||||
const [category, setCategory] = useState(product?.category ?? '');
|
||||
const [brandKeyword, setBrandKeyword] = useState(product?.brand_keyword ?? '');
|
||||
const [styleTone, setStyleTone] = useState(product?.style_tone ?? '');
|
||||
const [targetAudience, setTargetAudience] = useState(product?.target_audience ?? '');
|
||||
const [sellingPointsText, setSellingPointsText] = useState(
|
||||
(product?.selling_points ?? []).join('\n'),
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) { setError('产品名不能为空'); return; }
|
||||
setSaving(true); setError('');
|
||||
const payload: CreateProductRequest = {
|
||||
name: name.trim(),
|
||||
category: category.trim(),
|
||||
source: product?.source ?? 'custom',
|
||||
selling_points: sellingPointsText.split('\n').map((s) => s.trim()).filter(Boolean),
|
||||
style_tone: styleTone.trim(),
|
||||
text_angles: product?.text_angles ?? [],
|
||||
custom_prompt: product?.custom_prompt ?? null,
|
||||
banned_word_ids: product?.banned_word_ids ?? [],
|
||||
brand_keyword: brandKeyword.trim() || null,
|
||||
target_audience: targetAudience.trim() || null,
|
||||
};
|
||||
try {
|
||||
const saved = isEdit
|
||||
? await api.put<Product>(`/api/v1/products/${product!.id}`, payload)
|
||||
: await api.post<Product>('/api/v1/products', payload);
|
||||
onSaved(saved);
|
||||
} catch (err) {
|
||||
setError((err as { message?: string })?.message || '保存失败,请重试');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange';
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave}
|
||||
className="border border-brand-orange/30 rounded-xl p-5 bg-brand-cream/40 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-text-primary">{isEdit ? '编辑产品档案' : '新增产品'}</h3>
|
||||
<button type="button" onClick={onCancel}
|
||||
className="text-xs text-text-tertiary hover:text-text-secondary">收起</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="pf-name" className="block text-xs text-text-secondary mb-1">产品名 *</label>
|
||||
<input id="pf-name" value={name} onChange={(e) => setName(e.target.value)}
|
||||
maxLength={64} className={inputCls} required aria-required="true" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="pf-cat" className="block text-xs text-text-secondary mb-1">品类(选填)</label>
|
||||
<input id="pf-cat" value={category} onChange={(e) => setCategory(e.target.value)}
|
||||
maxLength={64} placeholder="如:护肤-素颜霜" className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="pf-brand" className="block text-xs text-text-secondary mb-1">品牌词(植入文案+图片文字层)</label>
|
||||
<input id="pf-brand" value={brandKeyword} onChange={(e) => setBrandKeyword(e.target.value)}
|
||||
maxLength={32} placeholder="如:倍分子" className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="pf-aud" className="block text-xs text-text-secondary mb-1">目标人群</label>
|
||||
<input id="pf-aud" value={targetAudience} onChange={(e) => setTargetAudience(e.target.value)}
|
||||
maxLength={128} placeholder="如:黄黑皮、熬夜党" className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="pf-style" className="block text-xs text-text-secondary mb-1">风格调性</label>
|
||||
<input id="pf-style" value={styleTone} onChange={(e) => setStyleTone(e.target.value)}
|
||||
maxLength={128} placeholder="如:真实分享、闺蜜种草" className={inputCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="pf-sp" className="block text-xs text-text-secondary mb-1">
|
||||
核心卖点(每行一条,越具体生成越贴合)
|
||||
</label>
|
||||
<textarea id="pf-sp" value={sellingPointsText} onChange={(e) => setSellingPointsText(e.target.value)}
|
||||
rows={5} placeholder={'烟酰胺改善暗沉,自然提亮\n水润奶油质地,丝滑好涂开\n上脸不卡纹不假白'}
|
||||
className={inputCls + ' resize-y'} />
|
||||
</div>
|
||||
|
||||
{error && <p role="alert" className="text-xs text-status-error">{error}</p>}
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button type="button" onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving}
|
||||
className="px-4 py-2 text-sm bg-brand-orange text-white rounded-lg hover:bg-orange-600 disabled:opacity-50">
|
||||
{saving ? '保存中…' : (isEdit ? '保存修改' : '保存')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -94,9 +94,17 @@ export function ProductImageManager({
|
||||
{images.map((im: ProductImage) => (
|
||||
<div key={im.id}
|
||||
className="flex items-center gap-2 border border-border-default rounded-lg p-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={toUploadSrc(im.path)} alt={`${sceneLabel(im.scene)}图`}
|
||||
className="h-12 w-12 object-cover rounded border border-border-default shrink-0" />
|
||||
<div className="relative shrink-0">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={toUploadSrc(im.path)} alt={`${sceneLabel(im.scene)}图`}
|
||||
className="h-20 w-20 object-cover rounded border border-border-default" />
|
||||
{/* 图上叠当前场景徽章:放大缩略图+一眼可辨,防止瓶身/质地图标反 */}
|
||||
<span className={`absolute bottom-0 left-0 right-0 text-[10px] text-center
|
||||
py-0.5 rounded-b text-white truncate
|
||||
${im.is_primary ? 'bg-brand-orange/90' : 'bg-black/60'}`}>
|
||||
{sceneLabel(im.scene)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<select value={im.scene} disabled={busyId === im.id}
|
||||
onChange={(e) => changeScene(im.id, e.target.value)}
|
||||
@@ -140,11 +148,16 @@ export function ProductImageManager({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{images.length === 0 && (
|
||||
{images.length === 0 ? (
|
||||
<p className="text-xs text-text-tertiary leading-relaxed">
|
||||
首张必须含产品主体(瓶身/包装本体)当主图,生图以此为锚点保证产品一致。
|
||||
质地图、上脸图等设对应场景类型,生图会按分镜自动选用。
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-amber-600 leading-relaxed">
|
||||
⚠️ 核对每张图下方场景:「主图/白底」必须是产品瓶身本体(生图的产品锚点),
|
||||
质地/上脸图请勿误标成主图,否则生图产品会走样。
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
<input ref={inputRef} type="file" accept="image/jpeg,image/png,image/webp"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
/**
|
||||
* ProductCard + ProductForm + ProductsSkeleton — 产品档案子组件
|
||||
* ProductCard + ProductsSkeleton — 产品档案子组件(编辑/新建统一走 ProductFormFull)
|
||||
* 产品多图管理拆到 ProductImageManager(R5)。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Product, CreateProductRequest } from '@/types/index';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { Product } from '@/types/index';
|
||||
import { ProductImageManager } from './ProductImageManager';
|
||||
import { ProductFormFull } from './ProductFormFull';
|
||||
|
||||
// ProductImageUpload 旧单图组件已被 ProductImageManager(多图+场景)取代。
|
||||
// 保留导出名向后兼容引用方。
|
||||
@@ -14,14 +16,48 @@ export { ProductImageManager as ProductImageUpload };
|
||||
|
||||
export function ProductCard({ product, onUpdated }: { product: Product; onUpdated: () => void }) {
|
||||
const [current, setCurrent] = useState<Product>(product);
|
||||
const role = useAuthStore((s) => s.role);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true); setError('');
|
||||
try {
|
||||
await api.delete(`/api/v1/products/${current.id}`);
|
||||
setConfirming(false);
|
||||
onUpdated();
|
||||
} catch (err) {
|
||||
setError((err as { message?: string })?.message || '删除失败,请重试');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<ProductFormFull
|
||||
product={current}
|
||||
onSaved={(p) => { setCurrent(p); setEditing(false); onUpdated(); }}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border-default rounded-xl p-4 bg-surface-primary space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-text-primary">{current.name}</h3>
|
||||
<span className="text-xs text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
|
||||
{current.category}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setEditing(true)}
|
||||
className="text-xs text-brand-orange border border-brand-orange/40 rounded-lg px-2 py-0.5 hover:bg-brand-orange/10">
|
||||
编辑
|
||||
</button>
|
||||
<span className="text-xs text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
|
||||
{current.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary line-clamp-2">
|
||||
{current.selling_points.join(' · ')}
|
||||
@@ -37,70 +73,37 @@ export function ProductCard({ product, onUpdated }: { product: Product; onUpdate
|
||||
product={current}
|
||||
onChanged={(updated) => { setCurrent(updated); onUpdated(); }}
|
||||
/>
|
||||
{role === 'admin' && (
|
||||
<div className="pt-2 border-t border-border-default">
|
||||
{!confirming ? (
|
||||
<button onClick={() => setConfirming(true)}
|
||||
className="text-xs text-status-error border border-status-error rounded-lg px-3 py-1 hover:bg-red-50">
|
||||
删除产品
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-text-secondary">
|
||||
确定删除产品「{current.name}」吗?删除后不可恢复(已有历史任务的产品将停用并保留记录)。
|
||||
</p>
|
||||
{error && <p role="alert" className="text-xs text-status-error">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => { setConfirming(false); setError(''); }} disabled={deleting}
|
||||
className="text-xs text-text-secondary border border-border-default rounded-lg px-3 py-1 hover:bg-surface-tertiary disabled:opacity-50">
|
||||
取消
|
||||
</button>
|
||||
<button onClick={handleDelete} disabled={deleting}
|
||||
className="text-xs text-white bg-status-error rounded-lg px-3 py-1 hover:opacity-90 disabled:opacity-50">
|
||||
{deleting ? '删除中…' : '确认删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductForm({ onSaved, onCancel }: { onSaved: () => void; onCancel: () => void }) {
|
||||
const [form, setForm] = useState<CreateProductRequest>({
|
||||
name: '', category: '', source: 'custom',
|
||||
selling_points: [], style_tone: '', text_angles: [],
|
||||
custom_prompt: null, banned_word_ids: [],
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) { setError('请填写产品名称'); return; }
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await api.post('/api/v1/products', form);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
const msg = (err as { message?: string })?.message;
|
||||
setError(msg || '保存失败,请重试');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave}
|
||||
className="border border-border-default rounded-xl p-6 bg-surface-primary space-y-4">
|
||||
<h3 className="font-medium text-text-primary">新增产品</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="prod-name" className="block text-sm font-medium mb-1">产品名称</label>
|
||||
<input id="prod-name" value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
|
||||
required aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="prod-category" className="block text-sm font-medium mb-1">品类</label>
|
||||
<input id="prod-category" value={form.category}
|
||||
onChange={(e) => setForm((f) => ({ ...f, category: e.target.value }))}
|
||||
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button type="button" onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving}
|
||||
className="px-4 py-2 text-sm bg-brand-orange text-white rounded-lg hover:bg-orange-600 disabled:opacity-50">
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductsSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4" aria-busy="true" aria-label="加载产品档案中">
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Product } from '@/types/index';
|
||||
import { PaginatedResponse } from '@/types/index';
|
||||
import { ProductCard, ProductForm, ProductsSkeleton } from './ProductSubComponents';
|
||||
import { ProductCard, ProductsSkeleton } from './ProductSubComponents';
|
||||
import { ProductFormFull } from './ProductFormFull';
|
||||
|
||||
export function ProductsTab() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
@@ -70,7 +71,7 @@ export function ProductsTab() {
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<ProductForm
|
||||
<ProductFormFull
|
||||
onSaved={() => { setShowForm(false); loadProducts(); }}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
|
||||
168
frontend/src/components/config/UserManageTab.tsx
Normal file
168
frontend/src/components/config/UserManageTab.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
/**
|
||||
* UserManageTab — 系统管理 → 账号管理(仅 admin)
|
||||
* 列出本 workspace 成员,建号,启用/禁用(软删除,可恢复)。
|
||||
* 倩倩姐2026-06-30:禁用=软删除随时恢复,不物理删。
|
||||
*/
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { ApiError } from '@/types';
|
||||
import { CreateUserModal } from './CreateUserModal';
|
||||
|
||||
interface WsUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
must_change_password: boolean;
|
||||
last_login_at: string | null;
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
admin: '管理员', supervisor: '组长', operator: '运营',
|
||||
};
|
||||
|
||||
function UserTable({ users, onToggle, onDelete, roleLabels }: {
|
||||
users: WsUser[];
|
||||
onToggle: (u: WsUser) => void;
|
||||
onDelete: (u: WsUser) => void;
|
||||
roleLabels: Record<string, string>;
|
||||
}) {
|
||||
if (users.length === 0) return <p className="text-sm text-text-tertiary">暂无账号。</p>;
|
||||
return (
|
||||
<table className="w-full text-sm border border-border-default rounded-lg overflow-hidden">
|
||||
<thead className="bg-surface-tertiary text-text-secondary">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 font-medium">用户名</th>
|
||||
<th className="text-left px-3 py-2 font-medium">角色</th>
|
||||
<th className="text-left px-3 py-2 font-medium">状态</th>
|
||||
<th className="text-left px-3 py-2 font-medium">最后登录</th>
|
||||
<th className="text-right px-3 py-2 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-t border-border-default">
|
||||
<td className="px-3 py-2 text-text-primary">
|
||||
{u.username}
|
||||
{u.must_change_password && <span className="ml-2 text-xs text-brand-orange">待改密</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-text-secondary">{roleLabels[u.role] || u.role}</td>
|
||||
<td className="px-3 py-2">
|
||||
{u.is_active
|
||||
? <span className="text-status-success">启用</span>
|
||||
: <span className="text-text-tertiary">已禁用</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-text-tertiary text-xs">
|
||||
{u.last_login_at ? new Date(u.last_login_at).toLocaleString('zh-CN') : '从未登录'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button onClick={() => onToggle(u)}
|
||||
className={`text-xs px-3 py-1 rounded-md border transition-colors ${
|
||||
u.is_active
|
||||
? 'border-border-default text-text-secondary hover:bg-surface-tertiary'
|
||||
: 'border-brand-orange text-brand-orange hover:bg-brand-orange-light'
|
||||
}`}>
|
||||
{u.is_active ? '禁用' : '恢复'}
|
||||
</button>
|
||||
<button onClick={() => onDelete(u)}
|
||||
className="ml-2 text-xs px-3 py-1 rounded-md border border-status-error text-status-error hover:bg-red-50 transition-colors">
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserManageTab() {
|
||||
const [users, setUsers] = useState<WsUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [pendingDelete, setPendingDelete] = useState<WsUser | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await api.get<WsUser[]>('/api/v1/users');
|
||||
setUsers(res || []);
|
||||
} catch (err) {
|
||||
setError((err as ApiError)?.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function toggleActive(u: WsUser) {
|
||||
try {
|
||||
await api.post(`/api/v1/users/${u.id}/active`, { is_active: !u.is_active });
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as ApiError)?.message || '操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!pendingDelete) return;
|
||||
try {
|
||||
await api.delete(`/api/v1/users/${pendingDelete.id}`);
|
||||
setPendingDelete(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as ApiError)?.message || '删除失败');
|
||||
setPendingDelete(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-text-secondary">管理本工作区的登录账号,新账号首次登录须改密。</p>
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="bg-brand-orange text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-orange-600">
|
||||
+ 新建账号
|
||||
</button>
|
||||
</div>
|
||||
{error && <div role="alert" className="text-status-error text-sm bg-red-50 px-3 py-2 rounded-lg mb-3">{error}</div>}
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-tertiary">加载中…</p>
|
||||
) : (
|
||||
<UserTable users={users} onToggle={toggleActive} onDelete={setPendingDelete} roleLabels={ROLE_LABELS} />
|
||||
)}
|
||||
{pendingDelete && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-surface-primary rounded-xl p-6 w-[360px] shadow-xl">
|
||||
<h3 className="text-base font-medium text-text-primary mb-2">删除账号</h3>
|
||||
<p className="text-sm text-text-secondary mb-1">
|
||||
确定永久删除账号 <span className="font-medium text-text-primary">{pendingDelete.username}</span> 吗?
|
||||
</p>
|
||||
<p className="text-xs text-status-error mb-5">删除后不可恢复,与「禁用」不同。如只是暂停使用请用禁用。</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setPendingDelete(null)}
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border-default text-text-secondary hover:bg-surface-tertiary">
|
||||
取消
|
||||
</button>
|
||||
<button onClick={confirmDelete}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-status-error text-white hover:bg-red-600">
|
||||
确认删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showCreate && (
|
||||
<CreateUserModal
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={() => { setShowCreate(false); load(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { api } from '@/lib/api';
|
||||
import { TaskListItem } from '@/types';
|
||||
import { getErrorAction } from '@/types/errors';
|
||||
import { clsx } from 'clsx';
|
||||
import { FissionSourceCard } from './FissionSourceCard';
|
||||
|
||||
interface Props {
|
||||
sources: TaskListItem[];
|
||||
@@ -48,18 +49,22 @@ export function FissionLauncher({ sources, onLaunched }: Props) {
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* 选源爆款 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">源爆款笔记</label>
|
||||
<select
|
||||
value={sourceId ?? ''}
|
||||
onChange={(e) => setSourceId(Number(e.target.value))}
|
||||
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
|
||||
>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
源爆款笔记
|
||||
<span className="text-xs text-text-tertiary font-normal ml-2">
|
||||
从你「已通过 / 已归档」的爆款里挑一条,裂变会借它的结构再产 N 套
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-72 overflow-auto pr-1">
|
||||
{sources.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
#{t.id} · {t.theme || '(无主题)'}
|
||||
</option>
|
||||
<FissionSourceCard
|
||||
key={t.id}
|
||||
task={t}
|
||||
selected={sourceId === t.id}
|
||||
onSelect={() => setSourceId(t.id)}
|
||||
/>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 参考强度 */}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
'use client';
|
||||
/**
|
||||
* FissionProgress — 裂变进度(轮询 GET /fission/{id} 聚合 x/N 完成)
|
||||
* 新架构:一次 LLM 出 N 套完整笔记包落 FissionNote,完成后进独立展示页看 N 套。
|
||||
* FissionProgress — 裂变进度(A3)
|
||||
* 保持轮询机制,用轮询返回的 done/total + 起始时间戳推算阶段和 ETA
|
||||
*/
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { StageLoadingPanel } from '@/components/common/StageLoadingPanel';
|
||||
|
||||
interface FissionStatus {
|
||||
fission_id: number;
|
||||
reference_level: string;
|
||||
fanout_count: number;
|
||||
status: 'generating' | 'completed' | 'failed';
|
||||
status: 'generating' | 'completed' | 'completed_with_fallback' | 'completed_fb' | 'failed';
|
||||
progress: { done: number; failed: number; total: number };
|
||||
}
|
||||
|
||||
@@ -20,9 +21,36 @@ interface Props {
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
const FISSION_STAGES = [
|
||||
{ label: '解析爆款结构' },
|
||||
{ label: '提炼卖点与人群' },
|
||||
{ label: '撰写标题正文标签' },
|
||||
{ label: '规划叙事链路和图文分镜' },
|
||||
{ label: '生成图片与质检' },
|
||||
{ label: '整理完整交付包' },
|
||||
];
|
||||
|
||||
/** 完成判定:兼容新短码 completed_fb 与历史旧值 completed_with_fallback */
|
||||
const isFissionDone = (s: string) =>
|
||||
s === 'completed' || s === 'completed_fb' || s === 'completed_with_fallback';
|
||||
/** 是否含品类兜底草稿(需人工复核提示) */
|
||||
const isFissionFallback = (s: string) =>
|
||||
s === 'completed_fb' || s === 'completed_with_fallback';
|
||||
|
||||
/** 根据 done/total 推算裂变阶段 */
|
||||
function calcFissionStageIdx(done: number, total: number, status: string): number {
|
||||
if (isFissionDone(status)) return FISSION_STAGES.length;
|
||||
if (done === 0) return 1; // 提炼卖点
|
||||
if (done < total * 0.3) return 2; // 撰写文案
|
||||
if (done < total * 0.6) return 3; // 规划叙事
|
||||
if (done < total) return 4; // 生成图片
|
||||
return 5; // 整理交付包
|
||||
}
|
||||
|
||||
export function FissionProgress({ fissionId, onReset }: Props) {
|
||||
const [data, setData] = useState<FissionStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const startedAtRef = useRef<number>(Date.now());
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
try {
|
||||
@@ -36,6 +64,7 @@ export function FissionProgress({ fissionId, onReset }: Props) {
|
||||
}, [fissionId]);
|
||||
|
||||
useEffect(() => {
|
||||
startedAtRef.current = Date.now();
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let stopped = false;
|
||||
const tick = async () => {
|
||||
@@ -58,7 +87,8 @@ export function FissionProgress({ fissionId, onReset }: Props) {
|
||||
if (!data) return <div className="py-12 text-center text-text-tertiary text-sm">启动裂变…</div>;
|
||||
|
||||
const { progress, status } = data;
|
||||
const pct = progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0;
|
||||
const activeIdx = calcFissionStageIdx(progress.done, progress.total, status);
|
||||
const allDone = isFissionDone(status);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -66,28 +96,31 @@ export function FissionProgress({ fissionId, onReset }: Props) {
|
||||
<h2 className="text-base font-semibold text-text-primary">
|
||||
裂变 #{data.fission_id} · {data.fanout_count} 套
|
||||
</h2>
|
||||
<span className="text-sm text-text-secondary">
|
||||
{progress.done}/{progress.total || data.fanout_count} 完成
|
||||
{progress.failed > 0 && <span className="text-red-500"> · {progress.failed} 失败</span>}
|
||||
</span>
|
||||
{progress.failed > 0 && (
|
||||
<span className="text-sm text-red-500">{progress.failed} 失败</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full h-2 bg-surface-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand-orange transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<StageLoadingPanel
|
||||
title={allDone ? `已生成 ${data.fanout_count} 套笔记包` : `正在裂变 ${progress.done}/${progress.total || data.fanout_count}`}
|
||||
stages={FISSION_STAGES}
|
||||
activeIdx={activeIdx}
|
||||
targetSec={95}
|
||||
startedAt={startedAtRef.current}
|
||||
className=""
|
||||
/>
|
||||
|
||||
{status === 'generating' && (
|
||||
<p className="text-sm text-text-tertiary">正在一次性生成 {data.fanout_count} 套差异化笔记包,请稍候…</p>
|
||||
)}
|
||||
{status === 'failed' && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-600">
|
||||
这批裂变全部生成失败,可点下方「再裂变一个」重试,或换一个源爆款。
|
||||
</div>
|
||||
)}
|
||||
{status === 'completed' && (
|
||||
{isFissionFallback(status) && (
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm text-amber-700">
|
||||
本次部分内容使用了品类兜底草稿,请人工复核标题、正文和分镜后再交付。
|
||||
</div>
|
||||
)}
|
||||
{allDone && (
|
||||
<Link
|
||||
href={`/fission/${data.fission_id}/notes`}
|
||||
className="self-start rounded-lg bg-brand-orange text-white px-5 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity"
|
||||
@@ -95,7 +128,6 @@ export function FissionProgress({ fissionId, onReset }: Props) {
|
||||
查看 {data.fanout_count} 套笔记包 →
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
|
||||
91
frontend/src/components/fission/FissionSourceCard.tsx
Normal file
91
frontend/src/components/fission/FissionSourceCard.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
/**
|
||||
* FissionSourceCard — 裂变源爆款卡片(#8 源爆款看不清/无图)
|
||||
* 富化展示:封面缩略图(懒拉首张已选图) + 主题 + 产品 + 文/图条数 + 状态徽章。
|
||||
* 替代原来干巴巴的下拉框,让运营看清"在拿哪条爆款去裂变"。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { TaskListItem } from '@/types';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
function toImgSrc(url: string) {
|
||||
if (!url) return '';
|
||||
if (/^https?:\/\//.test(url)) return url;
|
||||
return '/uploads/' + url.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '');
|
||||
}
|
||||
|
||||
export function FissionSourceCard({
|
||||
task, selected, onSelect,
|
||||
}: {
|
||||
task: TaskListItem;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const [cover, setCover] = useState<string>('');
|
||||
const [loadingCover, setLoadingCover] = useState(true);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [inView, setInView] = useState(false);
|
||||
|
||||
// 卡片进视口才拉封面,避免一次性 50 条全并发 GET 造成请求风暴(交叉验证#5)
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries[0]?.isIntersecting) { setInView(true); io.disconnect(); }
|
||||
}, { rootMargin: '100px' });
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
// 懒拉封面:取该任务首张已选图(没有则第一张)。列表接口无封面字段,按需单查。
|
||||
useEffect(() => {
|
||||
if (!inView) return;
|
||||
let alive = true;
|
||||
api.get<{ image_candidates?: { url: string; is_selected?: boolean }[] }>(`/api/v1/tasks/${task.id}`)
|
||||
.then((d) => {
|
||||
if (!alive) return;
|
||||
const imgs = d.image_candidates || [];
|
||||
const pick = imgs.find((i) => i.is_selected) || imgs[0];
|
||||
setCover(pick ? toImgSrc(pick.url) : '');
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { if (alive) setLoadingCover(false); });
|
||||
return () => { alive = false; };
|
||||
}, [inView, task.id]);
|
||||
|
||||
return (
|
||||
<button ref={ref} type="button" onClick={onSelect}
|
||||
className={clsx(
|
||||
'flex gap-3 w-full text-left border rounded-lg p-3 transition-colors',
|
||||
selected ? 'border-brand-orange bg-brand-orange-light' : 'border-border-default hover:border-brand-orange',
|
||||
)}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<div className="w-16 h-16 rounded-md bg-surface-tertiary shrink-0 overflow-hidden flex items-center justify-center">
|
||||
{cover ? (
|
||||
<img src={cover} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs text-text-tertiary">{loadingCover ? '…' : '无图'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-text-primary truncate">
|
||||
{task.theme || '(无主题)'}
|
||||
</div>
|
||||
<div className="text-xs text-text-tertiary mt-0.5 truncate">
|
||||
{task.product_name || `产品#${task.product_id}`} · #{task.id}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-xs text-text-secondary">{task.text_count} 文 · {task.image_count} 图</span>
|
||||
<span className={clsx(
|
||||
'text-[10px] px-1.5 py-0.5 rounded',
|
||||
task.status === 'approved' ? 'bg-green-50 text-green-600' : 'bg-surface-tertiary text-text-tertiary',
|
||||
)}>
|
||||
{task.status === 'approved' ? '已通过' : '已归档'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* 角色守卫:/review 只组长+管理员;/settings 全员;/config 只管理员
|
||||
*/
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
@@ -14,16 +14,21 @@ interface NavItem {
|
||||
href: string;
|
||||
roles?: string[];
|
||||
icon: string;
|
||||
// 同一 pathname 下用 query 区分高亮(图片排版/历史归档都落 /history)
|
||||
view?: string;
|
||||
}
|
||||
|
||||
// 三并列顶层创作入口(倩倩姐2026-06-15拍板"三并列入口"):
|
||||
// 文案创作=开新任务;图片排版=历史任务列表里挑一条进图片步骤(图片排版必须有 task id,
|
||||
// 故落到 /history,每行提供"去排图"入口,不造无 task-id 的死链);裂变扩散=独立裂变页。
|
||||
// 图片排版与历史归档同落 /history,靠 ?view=images 区分页面标题+高亮(修#7名实不符)。
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ label: '工作台', href: '/dashboard', icon: '🏠' },
|
||||
{ label: '任务看板', href: '/board', icon: '📋' },
|
||||
{ label: '文案创作', href: '/tasks/new', icon: '✏️' },
|
||||
{ label: '图片排版', href: '/history', icon: '🖼️' },
|
||||
// 「图片排版」暂时隐藏(倩倩姐2026-06-26):当前它与「历史归档」同落 /history、
|
||||
// 仅靠 ?view=images 区分,界面一模一样无独立价值。待重新设计图片排版界面后再放开。
|
||||
// { label: '图片排版', href: '/history?view=images', icon: '🖼️', view: 'images' },
|
||||
{ label: '裂变扩散', href: '/fission', icon: '🌱' },
|
||||
{ label: '审核台', href: '/review', roles: ['supervisor', 'admin'], icon: '✅' },
|
||||
{ label: '历史归档', href: '/history', icon: '🗂️' },
|
||||
@@ -33,7 +38,9 @@ const NAV_ITEMS: NavItem[] = [
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { role } = useAuthStore();
|
||||
const currentView = searchParams.get('view');
|
||||
|
||||
const visible = NAV_ITEMS.filter(
|
||||
(item) => !item.roles || (role && item.roles.includes(role))
|
||||
@@ -45,7 +52,12 @@ export function Sidebar() {
|
||||
aria-label="主导航"
|
||||
>
|
||||
{visible.map((item) => {
|
||||
const active = pathname === item.href || pathname.startsWith(item.href + '/');
|
||||
const base = item.href.split('?')[0];
|
||||
// 同 pathname 下用 view 区分:图片排版(view=images) vs 历史归档(无 view)
|
||||
const pathMatch = pathname === base || pathname.startsWith(base + '/');
|
||||
const active = base === '/history'
|
||||
? pathMatch && (item.view ?? null) === currentView
|
||||
: pathMatch;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
/**
|
||||
* UserBadge — 顶部右上角当前用户
|
||||
* 只显示名称和角色,不显示 token 余额
|
||||
* 点击展开菜单:修改密码 / 退出(倩倩姐2026-06-30:改密要有主动入口)
|
||||
*/
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
@@ -15,6 +17,16 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
export default function UserBadge() {
|
||||
const { user, role, logout } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
return () => document.removeEventListener('mousedown', onClickOutside);
|
||||
}, []);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
@@ -24,17 +36,32 @@ export default function UserBadge() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-white text-sm">
|
||||
{role ? ROLE_LABELS[role] : ''} · {user.username}
|
||||
</span>
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
aria-label="退出登录"
|
||||
className="text-white/80 text-xs hover:text-white transition-colors"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="用户菜单"
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-2 text-white text-sm hover:text-white/90 transition-colors"
|
||||
>
|
||||
退出
|
||||
<span>{role ? ROLE_LABELS[role] : ''} · {user.username}</span>
|
||||
<span aria-hidden="true" className="text-xs">▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-2 w-40 bg-surface-primary rounded-lg shadow-lg border border-border-default py-1 z-50">
|
||||
<button
|
||||
onClick={() => { setOpen(false); router.push('/change-password'); }}
|
||||
className="w-full text-left px-4 py-2 text-sm text-text-primary hover:bg-surface-tertiary transition-colors"
|
||||
>
|
||||
🔑 修改密码
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface ReviewCardProps {
|
||||
}
|
||||
|
||||
export function ReviewCard({ item, onApprove, onReject }: ReviewCardProps) {
|
||||
const sets = item.selected_sets?.filter((s) => s.selected_text || s.selected_images.length) ?? [];
|
||||
return (
|
||||
<article
|
||||
className="border border-border-default rounded-xl bg-surface-primary p-4"
|
||||
@@ -26,22 +27,57 @@ export function ReviewCard({ item, onApprove, onReject }: ReviewCardProps) {
|
||||
<span className="text-xs text-text-secondary">{item.operator_name}</span>
|
||||
</div>
|
||||
<h3 className="font-medium text-text-primary text-sm line-clamp-1">{item.theme}</h3>
|
||||
{item.selected_text && (
|
||||
{sets.length === 0 && item.selected_text && (
|
||||
<p className="text-xs text-text-secondary mt-1 line-clamp-2">
|
||||
{item.selected_text.content}
|
||||
</p>
|
||||
)}
|
||||
{/* 质量分:审核可判断(C2)。合格线80=红线,不用组件默认85 */}
|
||||
{item.selected_text?.score && (
|
||||
{sets.length === 0 && item.selected_text?.score && (
|
||||
<ScoreDimBars score={item.selected_text.score} passLine={80} />
|
||||
)}
|
||||
</div>
|
||||
{item.selected_image && (
|
||||
{sets.length === 0 && item.selected_image && (
|
||||
<img src={item.selected_image.url} alt="已选图预览"
|
||||
className="w-16 h-16 rounded-lg object-cover flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sets.length > 0 && (
|
||||
<div className="mt-4 grid gap-3">
|
||||
{sets.map((set) => (
|
||||
<section key={set.strategy} className="rounded-lg border border-border-default p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-semibold text-brand-orange">套{set.strategy}</span>
|
||||
<span className="text-xs text-text-tertiary">{set.selected_images.length} 张图</span>
|
||||
</div>
|
||||
{set.selected_text && (
|
||||
<>
|
||||
<p className="text-xs text-text-secondary line-clamp-2">
|
||||
{set.selected_text.content}
|
||||
</p>
|
||||
{set.selected_text.score && (
|
||||
<ScoreDimBars score={set.selected_text.score} passLine={80} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{set.selected_images.length > 0 && (
|
||||
<div className="mt-2 flex gap-2 overflow-x-auto">
|
||||
{set.selected_images.filter(Boolean).map((img) => (
|
||||
<img
|
||||
key={img!.candidate_id}
|
||||
src={img!.url}
|
||||
alt={`套${set.strategy}已选图`}
|
||||
className="h-16 w-12 flex-shrink-0 rounded-md object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作栏 — 飞轮最强信号 */}
|
||||
<div className="flex gap-3 mt-4 pt-4 border-t border-border-default">
|
||||
<button onClick={onApprove}
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
* [编辑配置]→侧滑面板原地编辑,改完实时刷新预览
|
||||
*/
|
||||
import { Product } from '@/types/index';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
interface ConfigPreviewPanelProps {
|
||||
product: Product;
|
||||
onEditInline: () => void; // 非管理员(运营/组长):就地编辑当前产品,不离开文案创作页
|
||||
}
|
||||
|
||||
export function ConfigPreviewPanel({ product }: ConfigPreviewPanelProps) {
|
||||
export function ConfigPreviewPanel({ product, onEditInline }: ConfigPreviewPanelProps) {
|
||||
const { role } = useAuthStore();
|
||||
return (
|
||||
<aside
|
||||
className="w-64 border-l border-border-default bg-surface-primary p-4 overflow-auto flex-shrink-0"
|
||||
@@ -18,8 +21,13 @@ export function ConfigPreviewPanel({ product }: ConfigPreviewPanelProps) {
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-text-primary">配置预览</h3>
|
||||
<a href="/config" aria-label="前往配置中心编辑"
|
||||
className="text-xs text-brand-orange hover:underline">编辑配置</a>
|
||||
{role === 'admin' ? (
|
||||
<a href="/settings?tab=products" aria-label="前往产品档案管理页"
|
||||
className="text-xs text-brand-orange hover:underline">编辑配置</a>
|
||||
) : (
|
||||
<button type="button" onClick={onEditInline} aria-label="就地编辑当前产品配置"
|
||||
className="text-xs text-brand-orange hover:underline">编辑配置</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
|
||||
@@ -4,18 +4,25 @@ interface ConfirmActionBarProps {
|
||||
submitting: boolean;
|
||||
regenerating: boolean;
|
||||
canSubmit: boolean;
|
||||
hydrating?: boolean; // C1: hydrate 期间显示加载态文字,避免无提示灰死
|
||||
onSubmit: () => void;
|
||||
onRegenerate: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmActionBar({
|
||||
submitting, regenerating, canSubmit, onSubmit, onRegenerate,
|
||||
submitting, regenerating, canSubmit, hydrating = false, onSubmit, onRegenerate,
|
||||
}: ConfirmActionBarProps) {
|
||||
const submitLabel = hydrating
|
||||
? '正在还原选中…'
|
||||
: submitting
|
||||
? '提交中…'
|
||||
: '提交审核';
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 mt-8">
|
||||
<button
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating || submitting}
|
||||
disabled={regenerating || submitting || hydrating}
|
||||
aria-label="重新生成(飞轮-1)"
|
||||
className="px-6 py-2.5 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary disabled:opacity-50"
|
||||
>
|
||||
@@ -23,11 +30,12 @@ export function ConfirmActionBar({
|
||||
</button>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={submitting || regenerating || !canSubmit}
|
||||
disabled={submitting || regenerating || !canSubmit || hydrating}
|
||||
aria-label="提交审核"
|
||||
title={!canSubmit && !hydrating ? '请至少选择 1 条文案和 1 张图片' : undefined}
|
||||
className="flex-1 bg-brand-orange text-white rounded-lg py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? '提交中…' : '提交审核'}
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,10 @@ import { ImageCandidate } from '@/types/index';
|
||||
|
||||
interface ConfirmPreviewImagesProps {
|
||||
images: ImageCandidate[];
|
||||
onZoom?: (img: ImageCandidate) => void; // 传入则图片可点击放大(已通过只读查看用)
|
||||
}
|
||||
|
||||
export function ConfirmPreviewImages({ images }: ConfirmPreviewImagesProps) {
|
||||
export function ConfirmPreviewImages({ images, onZoom }: ConfirmPreviewImagesProps) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-secondary mb-3">已选图</h3>
|
||||
@@ -15,7 +16,11 @@ export function ConfirmPreviewImages({ images }: ConfirmPreviewImagesProps) {
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{images.map((img) => (
|
||||
<div key={img.candidate_id} className="rounded-xl overflow-hidden border border-border-default">
|
||||
<div
|
||||
key={img.candidate_id}
|
||||
className={`rounded-xl overflow-hidden border border-border-default ${onZoom ? 'cursor-zoom-in' : ''}`}
|
||||
onClick={onZoom ? () => onZoom(img) : undefined}
|
||||
>
|
||||
<img src={img.url} alt={`选中图片·${img.role}`}
|
||||
className="w-full aspect-square object-cover" />
|
||||
<p className="text-xs text-text-secondary text-center py-1">{img.role}</p>
|
||||
|
||||
66
frontend/src/components/tasks/DeleteTaskButton.tsx
Normal file
66
frontend/src/components/tasks/DeleteTaskButton.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
/**
|
||||
* DeleteTaskButton — 任务详情页右上角「删除整个任务」按钮(倩倩姐2026-06-26拍板)
|
||||
* 仅管理员可见;点击先弹确认,确认后调 DELETE /tasks/{id}。
|
||||
* 后端智能删:有成品→转归档保留,残次→物理删。删除成功跳回看板。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
export function DeleteTaskButton({ taskId }: { taskId: number }) {
|
||||
const isAdmin = useAuthStore((s) => s.role) === 'admin';
|
||||
const router = useRouter();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true);
|
||||
setErr('');
|
||||
try {
|
||||
await api.delete(`/api/v1/tasks/${taskId}`);
|
||||
router.push('/board');
|
||||
} catch (e: unknown) {
|
||||
const ae = e as { message?: string };
|
||||
setErr(ae.message || '删除失败');
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!confirming) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setConfirming(true)}
|
||||
className="rounded-lg border border-red-200 px-3 py-1.5 text-sm text-red-500 hover:bg-red-50"
|
||||
aria-label="删除整个任务"
|
||||
>
|
||||
删除任务
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{err && <span className="text-xs text-red-500">{err}</span>}
|
||||
<span className="text-sm text-text-secondary">确定删除整个任务?</span>
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={deleting}
|
||||
className="rounded-lg border border-border-default px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-secondary disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="rounded-lg bg-red-500 px-3 py-1.5 text-sm text-white hover:bg-red-600 disabled:opacity-50"
|
||||
>
|
||||
{deleting ? '删除中…' : '确认删除'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,65 @@
|
||||
'use client';
|
||||
/**
|
||||
* ImageProgressHeader — 生图进度标题 + 进度条
|
||||
* 用于屏4"挑图"顶部,能离开提示
|
||||
* ImageProgressHeader — 生图多步骤阶段进度(A1)
|
||||
* 数据来自 generationStore,用真实 SSE imageDone 推算当前阶段
|
||||
*/
|
||||
interface ImageProgressHeaderProps {
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { StageLoadingPanel } from '@/components/common/StageLoadingPanel';
|
||||
|
||||
const IMAGE_STAGES = [
|
||||
{ label: '整理产品图和参考图' },
|
||||
{ label: '规划点击-证明-转化分镜' },
|
||||
{ label: '逐张生成 3:4 图片' },
|
||||
{ label: '检查是否出现 App 截图/底栏' },
|
||||
{ label: '整理图片预览和同步包' },
|
||||
];
|
||||
|
||||
// 根据 imageDone/imageTotal 推算当前激活阶段
|
||||
function calcImageStageIdx(done: number, total: number, stage: string): number {
|
||||
if (stage === 'idle' || stage === 'analyzing' || stage === 'text') return 0;
|
||||
if (stage === 'done') return IMAGE_STAGES.length; // 全完成
|
||||
// 生图阶段:0=整理图, 1=规划分镜, 2=逐张生成(主阶段), 3=检查, 4=整理包
|
||||
if (done === 0) return 1; // 还没出图:规划阶段
|
||||
if (done < total) return 2; // 出图中:逐张生成
|
||||
if (done >= total && total > 0) return 4; // 全出完:整理包(最后一步)
|
||||
return 2;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
imageTotal: number;
|
||||
imageDone: number;
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
export function ImageProgressHeader({ imageTotal, imageDone, pendingCount }: ImageProgressHeaderProps) {
|
||||
const pct = imageTotal > 0 ? Math.round((imageDone / imageTotal) * 100) : 0;
|
||||
export function ImageProgressHeader({ imageTotal, imageDone, pendingCount }: Props) {
|
||||
const { stage, startedAt } = useGenerationStore();
|
||||
const allDone = imageTotal > 0 && pendingCount === 0;
|
||||
|
||||
const activeIdx = calcImageStageIdx(imageDone, imageTotal, stage);
|
||||
// 真实进度比例(出完再跳到 100%)
|
||||
const realPct = allDone
|
||||
? 100
|
||||
: imageTotal > 0
|
||||
? Math.min(96, Math.round((imageDone / imageTotal) * 85 + 14))
|
||||
: undefined;
|
||||
|
||||
// 骨架屏:还未收到图时展示占位
|
||||
const skeletonCount = imageCandidatesEmpty(imageDone) ? 6 : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-text-primary">挑图</h2>
|
||||
<p className="text-sm text-text-secondary mt-0.5">
|
||||
正在生成 {imageTotal} 批图… {imageDone}/{imageTotal}
|
||||
{pendingCount > 0 && ' · 可先去忙别的,好了会更新'}
|
||||
</p>
|
||||
</div>
|
||||
{imageTotal > 0 && (
|
||||
<span className="text-lg font-bold text-brand-orange">{imageDone}/{imageTotal}</span>
|
||||
)}
|
||||
</div>
|
||||
{imageTotal > 0 && (
|
||||
<div className="mb-4 h-2 bg-surface-tertiary rounded-full overflow-hidden"
|
||||
role="progressbar" aria-label={`图片生成进度 ${imageDone}/${imageTotal}`}
|
||||
aria-valuenow={imageDone} aria-valuemin={0} aria-valuemax={imageTotal}>
|
||||
<div className="h-full bg-brand-orange rounded-full transition-all"
|
||||
style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<StageLoadingPanel
|
||||
title={allDone ? `已生成 ${imageTotal} 张图` : `正在生成图片 ${imageDone}/${imageTotal || '…'}`}
|
||||
stages={IMAGE_STAGES}
|
||||
activeIdx={activeIdx}
|
||||
targetSec={62}
|
||||
startedAt={startedAt}
|
||||
realPct={realPct}
|
||||
skeletonCount={skeletonCount}
|
||||
className="mb-4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function imageCandidatesEmpty(done: number) {
|
||||
return done === 0;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
// ImageStrategyGroup — 按"套"(A/B/C)分组展示挑图,组内按 seq 固定6图序,点图放大
|
||||
import { useState } from 'react';
|
||||
import { ImageCandidate } from '@/types/index';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
// 套别中文标签(三套正交叙事:痛点先行/场景先行/成分背书先行)
|
||||
export const STRATEGY_LABEL: Record<string, string> = {
|
||||
@@ -32,11 +34,29 @@ interface ImageStrategyGroupProps {
|
||||
onToggle: (id: number) => void;
|
||||
onZoom: (c: ImageCandidate) => void;
|
||||
onRegen?: (strategy: string, role: string | undefined, label: string) => void;
|
||||
regeneratingKeys?: Set<string>;
|
||||
onDelete?: (id: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ImageStrategyGroup({
|
||||
strategy, candidates, selectedImageIds, onToggle, onZoom, onRegen,
|
||||
strategy, candidates, selectedImageIds, onToggle, onZoom, onRegen, regeneratingKeys, onDelete,
|
||||
}: ImageStrategyGroupProps) {
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
// 删除权限仅管理员(倩倩姐2026-06-30拍板)
|
||||
const isAdmin = useAuthStore((s) => s.role) === 'admin';
|
||||
|
||||
async function handleDelete(candidateId: number) {
|
||||
if (!onDelete || deletingId !== null) return;
|
||||
setDeletingId(candidateId);
|
||||
try {
|
||||
await onDelete(candidateId);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setConfirmDeleteId(null);
|
||||
}
|
||||
}
|
||||
|
||||
// 组内按 seq 升序(北哥固定序);seq 缺失的排末尾
|
||||
const ordered = [...candidates].sort(
|
||||
(a, b) => (a.seq ?? 999) - (b.seq ?? 999)
|
||||
@@ -52,9 +72,10 @@ export function ImageStrategyGroup({
|
||||
{onRegen && (
|
||||
<button
|
||||
onClick={() => onRegen(strategy, undefined, STRATEGY_LABEL[strategy] || `套${strategy}`)}
|
||||
disabled={regeneratingKeys?.has(`${strategy}:*`)}
|
||||
className="ml-auto rounded-md border border-border-default px-2 py-0.5 text-xs font-normal text-text-secondary hover:border-brand-orange hover:text-brand-orange"
|
||||
>
|
||||
⟳ 重生整套
|
||||
{regeneratingKeys?.has(`${strategy}:*`) ? '重生中…' : '⟳ 重生整套'}
|
||||
</button>
|
||||
)}
|
||||
</h3>
|
||||
@@ -74,10 +95,11 @@ export function ImageStrategyGroup({
|
||||
{onRegen && (
|
||||
<button
|
||||
onClick={() => onRegen(strategy, c.role, ROLE_LABEL[c.role] || c.role)}
|
||||
disabled={regeneratingKeys?.has(`${strategy}:${c.role}`) || regeneratingKeys?.has(`${strategy}:*`)}
|
||||
aria-label={`重生 ${ROLE_LABEL[c.role] || c.role}`}
|
||||
className="absolute bottom-9 right-1.5 z-10 rounded-md bg-black/55 px-1.5 py-0.5 text-[11px] text-white hover:bg-brand-orange"
|
||||
className="absolute bottom-9 right-1.5 z-10 rounded-md bg-black/55 px-1.5 py-0.5 text-[11px] text-white hover:bg-brand-orange disabled:cursor-not-allowed disabled:bg-black/35"
|
||||
>
|
||||
⟳ 重生
|
||||
{regeneratingKeys?.has(`${strategy}:${c.role}`) || regeneratingKeys?.has(`${strategy}:*`) ? '重生中…' : '⟳ 重生'}
|
||||
</button>
|
||||
)}
|
||||
<img
|
||||
@@ -95,6 +117,35 @@ export function ImageStrategyGroup({
|
||||
AI {Math.round(c.ai_visual_score)}
|
||||
</span>
|
||||
)}
|
||||
{/* 删除按钮(AI分下方;确认遮罩;仅管理员) */}
|
||||
{onDelete && isAdmin && confirmDeleteId !== c.candidate_id && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmDeleteId(c.candidate_id); }}
|
||||
aria-label={`删除 ${ROLE_LABEL[c.role] || c.role}`}
|
||||
className="absolute right-1.5 top-7 z-10 rounded-md bg-black/55 px-1.5 py-0.5 text-[11px] text-white hover:bg-status-error"
|
||||
>
|
||||
✕ 删
|
||||
</button>
|
||||
)}
|
||||
{onDelete && confirmDeleteId === c.candidate_id && (
|
||||
<div
|
||||
className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 rounded-xl bg-black/70"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="text-[11px] text-white text-center px-1">确认删除?</p>
|
||||
<div className="flex gap-1.5">
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); setConfirmDeleteId(null); }}
|
||||
className="text-[11px] bg-white/20 text-white rounded px-2 py-0.5 hover:bg-white/30">
|
||||
取消
|
||||
</button>
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); handleDelete(c.candidate_id); }}
|
||||
disabled={deletingId === c.candidate_id}
|
||||
className="text-[11px] bg-status-error text-white rounded px-2 py-0.5 hover:opacity-90 disabled:opacity-50">
|
||||
{deletingId === c.candidate_id ? '…' : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onToggle(c.candidate_id)}
|
||||
aria-label={`${selected ? '取消选择' : '选择'} ${ROLE_LABEL[c.role] || c.role}`}
|
||||
|
||||
@@ -4,12 +4,18 @@
|
||||
* 选产品 + 今天主题 + 数量 + 双轨入口
|
||||
*/
|
||||
import { Product, CreateTaskRequest, Benchmark } from '@/types/index';
|
||||
import { useState } from 'react';
|
||||
import { CountStepper } from './CountStepper';
|
||||
import { QuickProductCreate } from './QuickProductCreate';
|
||||
import { ProductEditPanel } from './ProductEditPanel';
|
||||
|
||||
interface NewTaskFormProps {
|
||||
products: Product[];
|
||||
selectedProduct: Product | null;
|
||||
onSelectProduct: (p: Product | null) => void;
|
||||
onProductCreated: (p: Product) => void;
|
||||
editingProduct: boolean; // 受控:就地编辑当前产品开关(提升到页面,供右侧「编辑配置」触发)
|
||||
setEditingProduct: (v: boolean) => void;
|
||||
benchmarks: Benchmark[];
|
||||
form: Omit<CreateTaskRequest, 'product_id'>;
|
||||
onFormChange: (patch: Partial<Omit<CreateTaskRequest, 'product_id'>>) => void;
|
||||
@@ -20,17 +26,19 @@ interface NewTaskFormProps {
|
||||
}
|
||||
|
||||
export function NewTaskForm({
|
||||
products, selectedProduct, onSelectProduct, benchmarks,
|
||||
products, selectedProduct, onSelectProduct, onProductCreated,
|
||||
editingProduct, setEditingProduct, benchmarks,
|
||||
form, onFormChange, submitting, error,
|
||||
onSubmitAi, onSubmitImport,
|
||||
}: NewTaskFormProps) {
|
||||
const [creatingNew, setCreatingNew] = useState(false);
|
||||
function adjustCount(field: 'text_count' | 'image_count', delta: number) {
|
||||
const max = field === 'image_count' ? 8 : 20;
|
||||
onFormChange({ [field]: Math.max(1, Math.min(max, form[field] + delta)) });
|
||||
}
|
||||
|
||||
// 只允许选已分析完(analyze_status=done)的标杆——未分析的无 features 注入无意义
|
||||
const usableBenchmarks = benchmarks.filter((b) => b.analyze_status === 'done');
|
||||
// 只允许选真实分析完成的标杆;fallback/failed 不注入,避免静默污染生成质量。
|
||||
const usableBenchmarks = benchmarks.filter((b) => b.analyze_status === 'done' && b.analysis_source !== 'fallback');
|
||||
function toggleBenchmark(id: number) {
|
||||
const cur = form.benchmark_ids || [];
|
||||
onFormChange({
|
||||
@@ -38,30 +46,78 @@ export function NewTaskForm({
|
||||
});
|
||||
}
|
||||
|
||||
// 产品参考图状态:image_path 绝对路径(/app/uploads/...)取末段映射成 /uploads/... 供前端访问
|
||||
const rawPath = selectedProduct?.image_path || '';
|
||||
const hasProductImage = !!rawPath;
|
||||
const imgSrc = rawPath
|
||||
? '/uploads/' + rawPath.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '')
|
||||
: '';
|
||||
// 产品参考图:优先用多图数组 images(每张带 scene 角色),向后兼容单 image_path
|
||||
const SCENE_LABEL: Record<string, string> = {
|
||||
primary: '主图/白底', scene: '使用场景', texture: '质地特写',
|
||||
ingredient: '成分/包装', model: '上脸/使用中',
|
||||
};
|
||||
const toSrc = (path: string) =>
|
||||
'/uploads/' + path.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '');
|
||||
const productImages = selectedProduct?.images ?? [];
|
||||
// 多图为空但有旧单图字段时,兜底成一张主图,避免老产品显示不出来
|
||||
const refImages = productImages.length > 0
|
||||
? productImages
|
||||
: (selectedProduct?.image_path
|
||||
? [{ id: -1, path: selectedProduct.image_path, scene: 'primary', is_primary: true, sort_order: 0 }]
|
||||
: []);
|
||||
const hasProductImage = refImages.length > 0;
|
||||
// 禁降级:产品入镜但无图 → 主轨按钮禁用
|
||||
const blockedByNoImage = form.need_product_image && !hasProductImage;
|
||||
|
||||
return (
|
||||
<form className="space-y-6 max-w-xl" noValidate>
|
||||
<div className="space-y-6 max-w-xl">{/* 外层用 div 不用 form:内含 ProductEditPanel 的 form,form 嵌 form 非法会 hydration 报错 */}
|
||||
{/* 选产品 */}
|
||||
<div>
|
||||
<label htmlFor="product-select" className="block text-sm font-medium text-text-primary mb-2">
|
||||
选产品
|
||||
</label>
|
||||
<select id="product-select"
|
||||
value={selectedProduct?.id ?? ''}
|
||||
onChange={(e) => onSelectProduct(products.find((p) => p.id === Number(e.target.value)) ?? null)}
|
||||
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
|
||||
aria-required="true"
|
||||
>
|
||||
{products.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label htmlFor="product-select" className="block text-sm font-medium text-text-primary">
|
||||
选产品
|
||||
</label>
|
||||
{!creatingNew && (
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedProduct && !editingProduct && (
|
||||
<button type="button" onClick={() => setEditingProduct(true)}
|
||||
className="text-xs text-text-secondary hover:text-brand-orange font-medium">
|
||||
✎ 编辑产品
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => { setCreatingNew(true); setEditingProduct(false); }}
|
||||
className="text-xs text-brand-orange hover:text-orange-600 font-medium">
|
||||
+ 新建产品
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{creatingNew ? (
|
||||
<QuickProductCreate
|
||||
onCreated={(p) => { setCreatingNew(false); onProductCreated(p); }}
|
||||
onCancel={() => setCreatingNew(false)}
|
||||
/>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-xs text-text-tertiary bg-surface-secondary rounded px-3 py-2">
|
||||
还没有产品。点右上「+ 新建产品」录入产品名并上传产品图。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<select id="product-select"
|
||||
value={selectedProduct?.id ?? ''}
|
||||
onChange={(e) => { setEditingProduct(false); onSelectProduct(products.find((p) => p.id === Number(e.target.value)) ?? null); }}
|
||||
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
|
||||
aria-required="true"
|
||||
>
|
||||
{products.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
{editingProduct && selectedProduct && (
|
||||
<div className="mt-3">
|
||||
<ProductEditPanel
|
||||
product={selectedProduct}
|
||||
onSaved={(p) => { onProductCreated(p); setEditingProduct(false); }}
|
||||
onClose={() => setEditingProduct(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 产品入镜开关 + 参考图状态 */}
|
||||
@@ -75,13 +131,30 @@ export function NewTaskForm({
|
||||
</label>
|
||||
{form.need_product_image && (
|
||||
hasProductImage ? (
|
||||
<div className="flex items-center gap-2 text-xs text-status-success">
|
||||
<img src={imgSrc} alt="产品参考图" className="w-12 h-12 rounded object-cover border border-border-default" />
|
||||
<span>✓ 已上传参考图,生图将带产品入镜</span>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-status-success">
|
||||
<span>✓ 已上传 {refImages.length} 张参考图,生图按场景角色自动选用</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{refImages.map((im) => (
|
||||
<div key={im.id} className="space-y-1">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={toSrc(im.path)} alt={`${SCENE_LABEL[im.scene] ?? im.scene}图`}
|
||||
className="w-full aspect-square rounded object-cover border border-border-default" />
|
||||
<p className="text-[10px] text-center text-text-secondary truncate">
|
||||
{im.is_primary && <span className="text-brand-orange">★</span>}
|
||||
{SCENE_LABEL[im.scene] ?? im.scene}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-tertiary">
|
||||
想加图/换图/改角色,点上方「✎ 编辑产品」。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-status-error bg-red-50 rounded px-3 py-2" role="alert">
|
||||
⚠ 该产品未上传参考图。请先到「配置-产品库」上传,或取消勾选「产品入镜」改用纯文生图。
|
||||
⚠ 该产品未上传参考图。请点上方「✎ 编辑产品」上传,或取消勾选「产品入镜」改用纯文生图。
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
@@ -127,12 +200,19 @@ export function NewTaskForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数量设定(不写死) */}
|
||||
<div className="flex gap-6">
|
||||
<CountStepper label="生几条文案" value={form.text_count}
|
||||
onInc={() => adjustCount('text_count', 1)} onDec={() => adjustCount('text_count', -1)} />
|
||||
<CountStepper label="生几批图" value={form.image_count}
|
||||
onInc={() => adjustCount('image_count', 1)} onDec={() => adjustCount('image_count', -1)} />
|
||||
{/* 数量设定(不写死)。套数固定3套(痛点/场景/成分背书三正交叙事,倩倩姐拍板不可调),
|
||||
用户只调每套张数;总图数 = 3套 × 每套张数 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-6">
|
||||
<CountStepper label="生几条文案" value={form.text_count}
|
||||
onInc={() => adjustCount('text_count', 1)} onDec={() => adjustCount('text_count', -1)} />
|
||||
<CountStepper label="每套几张图" value={form.image_count}
|
||||
onInc={() => adjustCount('image_count', 1)} onDec={() => adjustCount('image_count', -1)} />
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary bg-surface-secondary rounded px-3 py-2">
|
||||
固定生成 <span className="font-medium text-text-secondary">3 套</span>(痛点先行 / 场景先行 / 成分背书,三套不重复),
|
||||
每套 {form.image_count} 张,共 <span className="font-medium text-brand-orange">{form.image_count * 3} 张图</span>。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -143,7 +223,7 @@ export function NewTaskForm({
|
||||
|
||||
{/* 双轨入口 */}
|
||||
<div className="flex gap-3">
|
||||
<button type="submit" disabled={submitting || blockedByNoImage} onClick={onSubmitAi}
|
||||
<button type="button" disabled={submitting || blockedByNoImage} onClick={onSubmitAi}
|
||||
aria-label="轨A:批量生成文案"
|
||||
title={blockedByNoImage ? '该产品未上传参考图,请先上传或关闭产品入镜' : ''}
|
||||
className="flex-1 bg-brand-orange text-white rounded-lg py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50">
|
||||
@@ -155,6 +235,6 @@ export function NewTaskForm({
|
||||
导入外部文案
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
41
frontend/src/components/tasks/ProductEditPanel.tsx
Normal file
41
frontend/src/components/tasks/ProductEditPanel.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
/**
|
||||
* ProductEditPanel — 选中已有产品后就地编辑(开任务页「编辑配置」入口)
|
||||
* 倩倩姐2026-06-26:编辑配置与产品档案编辑统一走 ProductFormFull(全字段:卖点/风格/人群/品牌词)。
|
||||
* 本面板 = 完整字段表单 + 产品图管理(换图加图),两块各自实时落库。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Product } from '@/types/index';
|
||||
import { ProductFormFull } from '@/components/config/ProductFormFull';
|
||||
import { ProductImageManager } from '@/components/config/ProductImageManager';
|
||||
|
||||
export function ProductEditPanel({
|
||||
product,
|
||||
onSaved,
|
||||
onClose,
|
||||
}: {
|
||||
product: Product;
|
||||
onSaved: (p: Product) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// 图片改动实时反映;PUT 不含 images,合并保留图片态后回交父组件
|
||||
const [cur, setCur] = useState<Product>(product);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ProductFormFull
|
||||
product={cur}
|
||||
onSaved={(p) => {
|
||||
const merged = { ...p, images: cur.images, image_path: cur.image_path };
|
||||
setCur(merged);
|
||||
onSaved(merged);
|
||||
}}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
<div className="border border-border-default rounded-lg p-4 bg-surface-primary">
|
||||
<p className="text-xs text-text-secondary mb-2">产品图(首张含瓶身当主图)</p>
|
||||
<ProductImageManager product={cur} onChanged={(u) => { setCur(u); onSaved(u); }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
frontend/src/components/tasks/QuickProductCreate.tsx
Normal file
91
frontend/src/components/tasks/QuickProductCreate.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
/**
|
||||
* QuickProductCreate — 开任务页内联快速建品(#1 选产品支持文字输入+就地上传)
|
||||
* 不跳配置页:输入产品名/品牌词 → 建产品 → 复用 ProductImageManager 就地传图。
|
||||
* 建好后通过 onCreated 把新产品回交父组件,自动选中。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Product } from '@/types/dto';
|
||||
import { ProductImageManager } from '@/components/config/ProductImageManager';
|
||||
|
||||
export function QuickProductCreate({
|
||||
onCreated,
|
||||
onCancel,
|
||||
}: {
|
||||
onCreated: (p: Product) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [brandKeyword, setBrandKeyword] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
// 已建出的产品(建完才能传图,因 upload-image 需 product_id)
|
||||
const [created, setCreated] = useState<Product | null>(null);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) { setError('请填写产品名'); return; }
|
||||
setError('');
|
||||
setCreating(true);
|
||||
try {
|
||||
const p = await api.post<Product>('/api/v1/products', {
|
||||
name: name.trim(),
|
||||
brand_keyword: brandKeyword.trim() || undefined,
|
||||
source: 'custom',
|
||||
});
|
||||
setCreated(p);
|
||||
} catch {
|
||||
setError('建产品失败,请重试');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-brand-orange/30 rounded-lg p-4 space-y-3 bg-brand-cream/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-text-primary">新建产品</span>
|
||||
<button type="button" onClick={onCancel}
|
||||
className="text-xs text-text-tertiary hover:text-text-secondary">取消</button>
|
||||
</div>
|
||||
|
||||
{!created ? (
|
||||
<>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="产品名,如:倍分子素颜霜" maxLength={64}
|
||||
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
|
||||
aria-label="产品名" />
|
||||
<input type="text" value={brandKeyword} onChange={(e) => setBrandKeyword(e.target.value)}
|
||||
placeholder="品牌词(可选),如:倍分子"
|
||||
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
|
||||
aria-label="品牌词" />
|
||||
{error && <p className="text-xs text-red-500" role="alert">{error}</p>}
|
||||
<button type="button" onClick={handleCreate} disabled={creating}
|
||||
className="w-full bg-brand-orange text-white rounded-lg py-2 text-sm font-medium hover:bg-orange-600 disabled:opacity-50">
|
||||
{creating ? '创建中…' : '创建并上传产品图'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-status-success">✓ 产品「{created.name}」已创建,请上传产品图(首张需含瓶身主体当主图)</p>
|
||||
<ProductImageManager product={created}
|
||||
onChanged={(updated) => setCreated(updated)} />
|
||||
{(created.images?.length ?? 0) === 0 ? (
|
||||
<p className="text-xs text-status-error bg-red-50 rounded px-3 py-2" role="alert">
|
||||
还没上传产品图。产品入镜需要真品参考图过抽检,请先上传一张含瓶身的主图。
|
||||
</p>
|
||||
) : (
|
||||
<button type="button" onClick={() => onCreated(created)}
|
||||
className="w-full bg-brand-orange text-white rounded-lg py-2 text-sm font-medium hover:bg-orange-600">
|
||||
完成,用这个产品开任务 →
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => onCreated(created)}
|
||||
className="w-full text-xs text-text-tertiary hover:text-text-secondary py-1">
|
||||
暂不传图,直接使用(纯文生图,开任务时不勾「产品入镜」)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
frontend/src/components/tasks/StageProgress.tsx
Normal file
83
frontend/src/components/tasks/StageProgress.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
/**
|
||||
* StageProgress — 文案/图片生成阶段进度(A2)
|
||||
* scope=text:6 步文案阶段;scope=image:图片阶段显示全局 4 步
|
||||
* 用 StageLoadingPanel 统一渲染,真实 SSE 数据驱动
|
||||
*/
|
||||
import { useGenerationStore, GenStage } from '@/stores/generationStore';
|
||||
import { StageLoadingPanel } from '@/components/common/StageLoadingPanel';
|
||||
|
||||
// ─── 文案 6 步 ─────────────────────────────────────────────────────────────
|
||||
const TEXT_STAGES = [
|
||||
{ label: '整理产品资料' },
|
||||
{ label: '规划不同选题角度' },
|
||||
{ label: '生成候选文案池' },
|
||||
{ label: '90 分评分与去重' },
|
||||
{ label: '必要时自动优化' },
|
||||
{ label: '整理可发布稿' },
|
||||
];
|
||||
|
||||
// ─── 图片整体 4 步(scope=image 全局进度,不替代 ImageProgressHeader)────
|
||||
const IMAGE_OVERVIEW_STAGES = [
|
||||
{ label: '分析竞品标杆' },
|
||||
{ label: '生成候选文案' },
|
||||
{ label: '配图生成中' },
|
||||
{ label: '完成' },
|
||||
];
|
||||
|
||||
const ORDER: GenStage[] = ['idle', 'analyzing', 'text', 'image', 'done'];
|
||||
|
||||
/** 文案阶段(stage=analyzing/text)→ TEXT_STAGES 的激活索引 */
|
||||
function textActiveIdx(stage: GenStage, textDone: number, textTotal: number): number {
|
||||
if (stage === 'idle') return -1;
|
||||
if (stage === 'analyzing') return 0; // 整理产品资料
|
||||
if (stage === 'text') {
|
||||
if (textDone === 0) return 1; // 规划角度
|
||||
if (textDone < textTotal) return 2; // 生成中
|
||||
return 3; // 评分去重
|
||||
}
|
||||
return TEXT_STAGES.length; // 后续阶段=全完
|
||||
}
|
||||
|
||||
/** 图片概览阶段 → IMAGE_OVERVIEW_STAGES 的激活索引 */
|
||||
function overviewActiveIdx(stage: GenStage): number {
|
||||
const idx = ORDER.indexOf(stage);
|
||||
if (idx <= 0) return -1;
|
||||
return Math.min(idx - 1, IMAGE_OVERVIEW_STAGES.length - 1);
|
||||
}
|
||||
|
||||
export function StageProgress({ scope }: { scope: 'text' | 'image' }) {
|
||||
const { stage, textDone, textTotal, startedAt } = useGenerationStore();
|
||||
|
||||
if (stage === 'idle') return null;
|
||||
if (stage === 'failed') return null;
|
||||
|
||||
if (scope === 'text') {
|
||||
const activeIdx = textActiveIdx(stage, textDone, textTotal);
|
||||
const allDone = stage !== 'analyzing' && stage !== 'text';
|
||||
return (
|
||||
<StageLoadingPanel
|
||||
title="生成文案中"
|
||||
stages={TEXT_STAGES}
|
||||
activeIdx={allDone ? TEXT_STAGES.length : activeIdx}
|
||||
targetSec={58}
|
||||
startedAt={startedAt}
|
||||
className="mb-4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// scope=image:展示整体 4 步概览进度条
|
||||
const activeIdx = overviewActiveIdx(stage);
|
||||
const allDone = stage === 'done';
|
||||
return (
|
||||
<StageLoadingPanel
|
||||
title="整体生成进度"
|
||||
stages={IMAGE_OVERVIEW_STAGES}
|
||||
activeIdx={allDone ? IMAGE_OVERVIEW_STAGES.length : activeIdx}
|
||||
targetSec={120}
|
||||
startedAt={startedAt}
|
||||
className="mb-4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { TextCandidate } from '@/types/index';
|
||||
import { ScoreDimBars } from './ScoreDimBars';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { clsx } from 'clsx' ;
|
||||
|
||||
interface TextCandidateCardProps {
|
||||
@@ -10,6 +11,7 @@ interface TextCandidateCardProps {
|
||||
selected: boolean;
|
||||
onToggle: (id: number) => void;
|
||||
onSaveEdit?: (id: number, content: string) => Promise<void>;
|
||||
onDelete?: (id: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const BANNED_STATUS_STYLES = {
|
||||
@@ -25,12 +27,16 @@ const VERDICT_STYLES: Record<string, string> = {
|
||||
'不合格':'text-status-error',
|
||||
};
|
||||
|
||||
export function TextCandidateCard({ candidate, selected, onToggle, onSaveEdit }: TextCandidateCardProps) {
|
||||
export function TextCandidateCard({ candidate, selected, onToggle, onSaveEdit, onDelete }: TextCandidateCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(candidate.content);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
// 删除权限仅管理员(倩倩姐2026-06-30拍板):非管理员不渲染删除入口
|
||||
const isAdmin = useAuthStore((s) => s.role) === 'admin';
|
||||
|
||||
async function handleSave(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
@@ -46,6 +52,18 @@ export function TextCandidateCard({ candidate, selected, onToggle, onSaveEdit }:
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (!onDelete || deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await onDelete(candidate.candidate_id);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setConfirmDelete(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
onClick={() => onToggle(candidate.candidate_id)}
|
||||
@@ -65,6 +83,41 @@ export function TextCandidateCard({ candidate, selected, onToggle, onSaveEdit }:
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 删除按钮(悬停出现,仅管理员) */}
|
||||
{onDelete && isAdmin && !confirmDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmDelete(true); }}
|
||||
aria-label="删除此文案"
|
||||
className={clsx(
|
||||
'absolute top-2 text-xs text-text-tertiary hover:text-status-error transition-colors',
|
||||
candidate.source === 'import' ? 'right-14' : 'right-2',
|
||||
)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 删除确认 */}
|
||||
{confirmDelete && (
|
||||
<div
|
||||
className="absolute inset-0 z-10 flex flex-col items-center justify-center rounded-xl bg-surface-primary/95 gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="text-xs text-text-secondary">确定删除这条文案?</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); setConfirmDelete(false); }}
|
||||
className="text-xs text-text-secondary border border-border-default rounded-md px-2.5 py-1 hover:bg-surface-tertiary">
|
||||
取消
|
||||
</button>
|
||||
<button type="button" onClick={handleDelete} disabled={deleting}
|
||||
className="text-xs text-white bg-status-error rounded-md px-2.5 py-1 hover:opacity-90 disabled:opacity-50">
|
||||
{deleting ? '删除中…' : '确认删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 选中勾 */}
|
||||
{selected && (
|
||||
<span className="absolute top-2 left-2 w-5 h-5 bg-brand-orange text-white rounded-full flex items-center justify-center text-xs"
|
||||
|
||||
56
frontend/src/components/tasks/TextLightbox.tsx
Normal file
56
frontend/src/components/tasks/TextLightbox.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
// TextLightbox — 点击文案看全文(已通过任务只读查看用),ESC/点遮罩关闭
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface TextLightboxProps {
|
||||
text: string | null;
|
||||
label?: string;
|
||||
score?: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TextLightbox({ text, label, score, onClose }: TextLightboxProps) {
|
||||
useEffect(() => {
|
||||
if (!text) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [text, onClose]);
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="文案全文"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
>
|
||||
<div
|
||||
className="relative max-h-[85vh] w-full max-w-2xl overflow-auto rounded-xl bg-white p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="关闭全文"
|
||||
className="absolute top-3 right-3 h-8 w-8 rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{label && <span className="text-xs font-medium text-brand-orange">{label}</span>}
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-text-primary">{text}</p>
|
||||
{typeof score === 'number' && (
|
||||
<span className="mt-3 block text-xs text-text-tertiary">总分 {score}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/tasks/TextOutcomeBanner.tsx
Normal file
54
frontend/src/components/tasks/TextOutcomeBanner.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
/**
|
||||
* TextOutcomeBanner — 文案产出结果提示(B1)
|
||||
* task_done 后,若产出不足/评分不可用/全不合格,显式告诉用户真实原因,
|
||||
* 不再让页面停在骨架屏装"生成中",也不假装"完成"绿勾。
|
||||
* 倩倩姐2026-06-26:评分通道挂了要明确报错,不静默糊弄。
|
||||
*/
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
|
||||
export function TextOutcomeBanner() {
|
||||
const { stage, textOutcome } = useGenerationStore();
|
||||
|
||||
// 仅在任务跑完(task_done→done)后、且产出有异常时提示;正常足量不打扰
|
||||
if (stage !== 'done' || !textOutcome || textOutcome.reason === null) return null;
|
||||
|
||||
const { saved, target, reason } = textOutcome;
|
||||
|
||||
const MAP: Record<string, { tone: 'error' | 'warn'; title: string; desc: string }> = {
|
||||
scoring_unavailable: {
|
||||
tone: 'error',
|
||||
title: '评分服务暂不可用,文案未能定稿',
|
||||
desc: '评分通道(主+备)暂时连不上,本次没有产出合格文案——这不是文案质量问题。请确认中转站额度后,点下方「重试」重新生成。',
|
||||
},
|
||||
generation_failed: {
|
||||
tone: 'error',
|
||||
title: '文案生成失败',
|
||||
desc: '生成通道暂时不可用,本次未产出文案。请稍后点「重试」,或确认中转站 Key/额度是否正常。',
|
||||
},
|
||||
quality_filtered: {
|
||||
tone: 'warn',
|
||||
title: '本批文案未达发布标准',
|
||||
desc: `已生成的文案均未过 80 分合格线,系统正在后台补充更高质量的版本,请稍候刷新;若长时间没有,可点「重试」。`,
|
||||
},
|
||||
replenishing: {
|
||||
tone: 'warn',
|
||||
title: `已出 ${saved}/${target} 条,正在后台补充`,
|
||||
desc: '合格文案还差几条,系统正在后台继续生成,达到目标条数后会自动补上,无需离开页面。',
|
||||
},
|
||||
};
|
||||
|
||||
const cfg = MAP[reason];
|
||||
if (!cfg) return null;
|
||||
|
||||
const styles = cfg.tone === 'error'
|
||||
? 'bg-red-50 border-red-200 text-red-700'
|
||||
: 'bg-amber-50 border-amber-200 text-amber-700';
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border px-4 py-3 mb-4 ${styles}`} role="alert">
|
||||
<p className="font-semibold text-sm">{cfg.title}</p>
|
||||
<p className="text-xs mt-1 leading-relaxed">{cfg.desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,10 +15,11 @@ interface TextStrategyGroupProps {
|
||||
selectedTextIds: number[];
|
||||
onToggle: (id: number) => void;
|
||||
onSaveEdit?: (id: number, content: string) => Promise<void>;
|
||||
onDelete?: (id: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function TextStrategyGroup({
|
||||
strategy, candidates, selectedTextIds, onToggle, onSaveEdit,
|
||||
strategy, candidates, selectedTextIds, onToggle, onSaveEdit, onDelete,
|
||||
}: TextStrategyGroupProps) {
|
||||
const label = strategy === '_' ? '未分套' : (STRATEGY_LABEL[strategy] || `套${strategy}`);
|
||||
return (
|
||||
@@ -37,6 +38,7 @@ export function TextStrategyGroup({
|
||||
selected={selectedTextIds.includes(c.candidate_id)}
|
||||
onToggle={onToggle}
|
||||
onSaveEdit={onSaveEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,74 @@ import { useTaskStore } from '@/stores/taskStore';
|
||||
import { TextCandidate, ImageCandidate } from '@/types/index';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const DONE_STATUSES = ['pending_selection', 'pending_review', 'approved', 'rejected', 'archived'];
|
||||
|
||||
// 模块级"上一个任务id":组件内 useRef 在 SPA 切页时随组件重建归零,
|
||||
// 无法区分"切任务"还是"同任务跨页",导致清选中条件永不命中(残留串任务)。
|
||||
// 提到模块级后跨组件实例存活:同任务跨页(文案→图片→确认)不清,切新任务才清。
|
||||
let lastSeenTaskId: number | null = null;
|
||||
|
||||
type TaskSnapshot = {
|
||||
status?: string;
|
||||
text_count?: number;
|
||||
image_count?: number;
|
||||
text_candidates?: TextCandidate[];
|
||||
image_candidates?: ImageCandidate[];
|
||||
};
|
||||
|
||||
function imageTotalFromTask(task: TaskSnapshot, images: ImageCandidate[]): number {
|
||||
if (typeof task.image_count === 'number' && task.image_count > 0) {
|
||||
return task.image_count * 3; // A/B/C 三套图,每套 image_count 张
|
||||
}
|
||||
return images.length || 0;
|
||||
}
|
||||
|
||||
function applyTaskSnapshot(
|
||||
task: TaskSnapshot,
|
||||
store: ReturnType<typeof useGenerationStore.getState>,
|
||||
) {
|
||||
const texts = task.text_candidates ?? [];
|
||||
const images = task.image_candidates ?? [];
|
||||
if (texts.length || images.length) {
|
||||
store.hydrateFromTask({ textCandidates: texts, imageCandidates: images });
|
||||
}
|
||||
|
||||
const textTarget = task.text_count ?? texts.length;
|
||||
const imageTarget = imageTotalFromTask(task, images);
|
||||
store.setTextProgress(Math.min(texts.length, textTarget), textTarget);
|
||||
store.setImageProgress(Math.min(images.length, imageTarget), imageTarget);
|
||||
|
||||
// 历史/已完成任务 SSE 不重放 task_done;进行中任务也可能漏 SSE。
|
||||
// 所以快照是最终校准源,避免页面停在 0/4 或完成后又被迟到事件拉回生成中。
|
||||
if (task.status === 'failed') {
|
||||
store.setStage('failed');
|
||||
return;
|
||||
}
|
||||
if (task.status && DONE_STATUSES.includes(task.status)) {
|
||||
store.setStage('done');
|
||||
if (texts.length === 0) {
|
||||
store.setTextOutcome({
|
||||
saved: 0,
|
||||
target: textTarget,
|
||||
reason: 'quality_filtered',
|
||||
});
|
||||
} else {
|
||||
store.setTextOutcome({
|
||||
saved: texts.length,
|
||||
target: textTarget,
|
||||
reason: texts.length >= textTarget ? null : 'replenishing',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (images.length > 0) {
|
||||
store.setStage('image');
|
||||
} else if (texts.length > 0) {
|
||||
store.setStage('text');
|
||||
}
|
||||
}
|
||||
|
||||
export function useSse(taskId: number | null) {
|
||||
const clientRef = useRef<SseClient | null>(null);
|
||||
const store = useGenerationStore();
|
||||
@@ -20,24 +88,23 @@ export function useSse(taskId: number | null) {
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
store.reset();
|
||||
// 进入任务时清空上一个任务残留的选中态,避免"交审"按钮基于陈旧选择误判可点/置灰
|
||||
useTaskStore.getState().clearSelections();
|
||||
// 仅在切换到不同任务时才清选中态,同任务在不同页间跳转(文案页→图片页)不清。
|
||||
// lastSeenTaskId 为模块级,SPA 切页不归零,故 null→新任务 也会清(修复残留)。
|
||||
if (lastSeenTaskId !== taskId) {
|
||||
useTaskStore.getState().clearSelections();
|
||||
}
|
||||
lastSeenTaskId = taskId;
|
||||
|
||||
// reset 之后立即回填已有候选(历史/已完成任务 SSE 不重放)。
|
||||
// 必须放在 reset 之后、与 SSE 同一 effect 内,否则与页面侧 hydrate 形成
|
||||
// reset 把 hydrate 数据清空的竞态(SPA 跳转进来会看到空白)。
|
||||
let cancelled = false;
|
||||
api.get<{
|
||||
text_candidates?: TextCandidate[];
|
||||
image_candidates?: ImageCandidate[];
|
||||
}>(`/api/v1/tasks/${taskId}`).then((task) => {
|
||||
const loadSnapshot = () => api.get<TaskSnapshot>(`/api/v1/tasks/${taskId}`).then((task) => {
|
||||
if (cancelled) return;
|
||||
const texts = task.text_candidates ?? [];
|
||||
const images = task.image_candidates ?? [];
|
||||
if (texts.length || images.length) {
|
||||
store.hydrateFromTask({ textCandidates: texts, imageCandidates: images });
|
||||
}
|
||||
applyTaskSnapshot(task, store);
|
||||
}).catch(() => { /* 进行中任务靠 SSE 填充 */ });
|
||||
loadSnapshot();
|
||||
const snapshotTimer = window.setInterval(loadSnapshot, 5000);
|
||||
|
||||
const client = createSseClient(taskId, {
|
||||
onEvent: (event: SseEvent) => handleEvent(event, store, taskId),
|
||||
@@ -49,6 +116,7 @@ export function useSse(taskId: number | null) {
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(snapshotTimer);
|
||||
client.close();
|
||||
clientRef.current = null;
|
||||
};
|
||||
@@ -65,17 +133,28 @@ function handleEvent(
|
||||
store: ReturnType<typeof useGenerationStore.getState>,
|
||||
taskId: number,
|
||||
) {
|
||||
const currentStage = useGenerationStore.getState().stage;
|
||||
const isTerminal = currentStage === 'done' || currentStage === 'failed';
|
||||
|
||||
switch (event.type) {
|
||||
case 'task_started':
|
||||
store.setTextProgress(0, event.data.total_text);
|
||||
store.setImageProgress(0, event.data.total_image);
|
||||
store.setStartedAt(Date.now()); // A1/A2 ETA 计算起点
|
||||
// task_started→首条文案之间,后端在做竞品/飞轮分析,先停"分析竞品"阶段;
|
||||
// 文案事件一到立即推进到 text(后端无独立 analyze_done,靠文案事件兜底不会卡死)
|
||||
store.setStage('analyzing');
|
||||
break;
|
||||
|
||||
case 'text_progress':
|
||||
if (isTerminal) break;
|
||||
store.setStage('text');
|
||||
store.setTextProgress(event.data.done, event.data.total);
|
||||
break;
|
||||
|
||||
case 'text_candidate': {
|
||||
if (isTerminal) break;
|
||||
store.setStage('text'); // 文案候选先于 text_progress 到时,也立即脱离 analyzing
|
||||
// SSE 只推 total 分;先用 total 占位,再异步补全五维分
|
||||
const totalScore = typeof event.data.score === 'number' ? event.data.score : 0;
|
||||
const tc: TextCandidate = {
|
||||
@@ -99,10 +178,13 @@ function handleEvent(
|
||||
}
|
||||
|
||||
case 'image_progress':
|
||||
if (isTerminal) break;
|
||||
store.setStage('image');
|
||||
store.setImageProgress(event.data.done, event.data.total);
|
||||
break;
|
||||
|
||||
case 'image_candidate': {
|
||||
if (isTerminal) break;
|
||||
const ic: ImageCandidate = {
|
||||
candidate_id: event.data.candidate_id,
|
||||
strategy: event.data.strategy,
|
||||
@@ -125,17 +207,32 @@ function handleEvent(
|
||||
store.setFlywheelInjected({
|
||||
recentPreference: event.data.recent_preference,
|
||||
rejectReasons: event.data.reject_reasons,
|
||||
signalCount: event.data.signal_count,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
// 任务级致命错误:不再静默丢弃,落 fatalError 让 UI 提示(B6)
|
||||
store.setStage('failed');
|
||||
store.setFatalError(event.data.code, event.data.message);
|
||||
break;
|
||||
|
||||
case 'heartbeat':
|
||||
case 'analyze_done':
|
||||
// 竞品/标杆分析完成 → 进入文案阶段(此前停在 analyzing)
|
||||
store.setStage('text');
|
||||
break;
|
||||
|
||||
case 'task_done':
|
||||
store.setStage('done');
|
||||
// B1:落定文案产出结果,前端据此区分"完成/补充中/评分不可用/质量不合格"
|
||||
store.setTextOutcome({
|
||||
saved: event.data.text_saved ?? store.textCandidates.length,
|
||||
target: event.data.text_target ?? 0,
|
||||
reason: event.data.text_reason ?? null,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'heartbeat':
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,11 @@ async function refreshToken(): Promise<string | null> {
|
||||
try {
|
||||
// 走 /auth/refresh(二期实现),一期降级跳登录
|
||||
clearToken();
|
||||
window.location.href = '/login';
|
||||
// 带提示跳登录,避免“弹窗一闪就被踢、看不清原因”
|
||||
if (typeof window !== 'undefined') {
|
||||
const back = encodeURIComponent(window.location.pathname);
|
||||
window.location.href = `/login?reason=expired&from=${back}`;
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
@@ -67,7 +71,13 @@ async function request<T>(
|
||||
}
|
||||
}
|
||||
|
||||
throw err;
|
||||
const apiError = new Error(err.message || `请求失败(${err.code || res.status})`) as Error & {
|
||||
code?: number;
|
||||
status?: number;
|
||||
};
|
||||
apiError.code = err.code;
|
||||
apiError.status = res.status;
|
||||
throw apiError;
|
||||
}
|
||||
|
||||
// ─── 公开方法 ────────────────────────────────────────────────
|
||||
|
||||
@@ -70,11 +70,18 @@ export class SseClient {
|
||||
// SSE 必须直连后端,绕过 Next dev rewrite 代理——后者会缓冲/吞掉 EventSource 的
|
||||
// 流式事件(实测:经代理 EventSource 只触发 open,零事件;直连后端事件正常)。
|
||||
// NEXT_PUBLIC_SSE_BASE_URL 缺省时退回 NEXT_PUBLIC_API_BASE_URL,再退回相对路径。
|
||||
const sseBase = (
|
||||
let sseBase = (
|
||||
process.env.NEXT_PUBLIC_SSE_BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||
''
|
||||
).replace(/\/$/, '');
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
process.env.NODE_ENV === 'development' &&
|
||||
(window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost')
|
||||
) {
|
||||
sseBase = `http://${window.location.hostname}:8000`;
|
||||
}
|
||||
const url = `${sseBase}/api/v1/tasks/${this.taskId}/stream?ticket=${ticket}&last_seq=${lastSeq}`;
|
||||
this.es = new EventSource(url);
|
||||
this.es.onopen = () => this.options.onStatusChange('connected');
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { SseConnectionStatus } from '@/lib/sse';
|
||||
import { TextCandidate, ImageCandidate } from '@/types/index';
|
||||
// 懒引入 taskStore,避免循环依赖(taskStore 不引 generationStore)
|
||||
import { useTaskStore } from '@/stores/taskStore';
|
||||
|
||||
interface FailedBatch {
|
||||
batch: string; // 图片角色名("hook"/"pain"等),对应 BE batch_failed.batch 字段
|
||||
@@ -12,11 +14,32 @@ interface FailedBatch {
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
// 生成阶段(驱动阶段进度条,对齐产品包"有阶段进度"体验 #3#4)
|
||||
// idle→分析竞品→生成文案→配图→完成;failed 单列
|
||||
export type GenStage = 'idle' | 'analyzing' | 'text' | 'image' | 'done' | 'failed';
|
||||
|
||||
// 文案产出结果原因(B1)——task_done 透传,区分"完成但没出文案"的真实原因
|
||||
export type TextOutcomeReason =
|
||||
| 'scoring_unavailable' // 评分服务不可用(apiports+codeproxy都挂)
|
||||
| 'generation_failed' // 文案生成本身失败
|
||||
| 'quality_filtered' // 生成了但全部未达标
|
||||
| 'replenishing' // 有产出但不足,后台补充中
|
||||
| null; // 正常足量
|
||||
|
||||
export interface TextOutcome {
|
||||
saved: number;
|
||||
target: number;
|
||||
reason: TextOutcomeReason;
|
||||
}
|
||||
|
||||
interface GenerationState {
|
||||
// 连接状态
|
||||
connectionStatus: SseConnectionStatus;
|
||||
reconnectAttempt: number;
|
||||
|
||||
// 当前生成阶段(#3#4 阶段进度)
|
||||
stage: GenStage;
|
||||
|
||||
// 文案生成进度
|
||||
textDone: number;
|
||||
textTotal: number;
|
||||
@@ -35,18 +58,30 @@ interface GenerationState {
|
||||
flywheelInjected: {
|
||||
recentPreference: string;
|
||||
rejectReasons: string[];
|
||||
signalCount?: number;
|
||||
} | null;
|
||||
|
||||
// 任务启动时间戳(ms),task_started 事件到达时记录,用于 ETA 推算(A1/A2)
|
||||
startedAt: number | null;
|
||||
|
||||
// 文案产出结果(B1)——task_done 到达时落定,驱动"完成但没出文案"的明确提示
|
||||
textOutcome: TextOutcome | null;
|
||||
|
||||
setConnectionStatus: (s: SseConnectionStatus, attempt?: number) => void;
|
||||
setStage: (stage: GenStage) => void;
|
||||
setStartedAt: (ts: number) => void;
|
||||
setTextOutcome: (outcome: TextOutcome) => void;
|
||||
setTextProgress: (done: number, total: number) => void;
|
||||
addTextCandidate: (candidate: TextCandidate) => void;
|
||||
patchTextCandidateScore: (candidateId: number, score: TextCandidate['score']) => void;
|
||||
patchTextCandidateContent: (candidateId: number, content: string) => void;
|
||||
removeTextCandidate: (candidateId: number) => void;
|
||||
removeImageCandidate: (candidateId: number) => void;
|
||||
setImageProgress: (done: number, total: number) => void;
|
||||
addImageCandidate: (candidate: ImageCandidate) => void;
|
||||
markBatchFailed: (batch: string, reason: string, retryable: boolean) => void;
|
||||
setFatalError: (code: number, message: string) => void;
|
||||
setFlywheelInjected: (data: { recentPreference: string; rejectReasons: string[] }) => void;
|
||||
setFlywheelInjected: (data: { recentPreference: string; rejectReasons: string[]; signalCount?: number }) => void;
|
||||
hydrateFromTask: (data: {
|
||||
textCandidates: TextCandidate[];
|
||||
imageCandidates: ImageCandidate[];
|
||||
@@ -57,6 +92,9 @@ interface GenerationState {
|
||||
export const useGenerationStore = create<GenerationState>((set) => ({
|
||||
connectionStatus: 'connecting',
|
||||
reconnectAttempt: 0,
|
||||
stage: 'idle',
|
||||
startedAt: null,
|
||||
textOutcome: null,
|
||||
textDone: 0,
|
||||
textTotal: 0,
|
||||
textCandidates: [],
|
||||
@@ -71,12 +109,31 @@ export const useGenerationStore = create<GenerationState>((set) => ({
|
||||
set({ connectionStatus: s, reconnectAttempt: attempt });
|
||||
},
|
||||
|
||||
setStage(stage) {
|
||||
set({ stage });
|
||||
},
|
||||
|
||||
setStartedAt(ts) {
|
||||
set({ startedAt: ts });
|
||||
},
|
||||
|
||||
setTextOutcome(outcome) {
|
||||
set({ textOutcome: outcome });
|
||||
},
|
||||
|
||||
setTextProgress(done, total) {
|
||||
set({ textDone: done, textTotal: total });
|
||||
},
|
||||
|
||||
addTextCandidate(candidate) {
|
||||
set((s) => ({ textCandidates: [...s.textCandidates, candidate] }));
|
||||
set((s) => {
|
||||
const exists = s.textCandidates.some((tc) => tc.candidate_id === candidate.candidate_id);
|
||||
return {
|
||||
textCandidates: exists
|
||||
? s.textCandidates.map((tc) => tc.candidate_id === candidate.candidate_id ? { ...tc, ...candidate } : tc)
|
||||
: [...s.textCandidates, candidate],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
patchTextCandidateScore(candidateId, score) {
|
||||
@@ -95,12 +152,34 @@ export const useGenerationStore = create<GenerationState>((set) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
removeTextCandidate(candidateId) {
|
||||
set((s) => ({
|
||||
textCandidates: s.textCandidates.filter((tc) => tc.candidate_id !== candidateId),
|
||||
}));
|
||||
// 同步清选中态
|
||||
useTaskStore.getState().deselectText(candidateId);
|
||||
},
|
||||
|
||||
removeImageCandidate(candidateId) {
|
||||
set((s) => ({
|
||||
imageCandidates: s.imageCandidates.filter((ic) => ic.candidate_id !== candidateId),
|
||||
}));
|
||||
useTaskStore.getState().deselectImage(candidateId);
|
||||
},
|
||||
|
||||
setImageProgress(done, total) {
|
||||
set({ imageDone: done, imageTotal: total });
|
||||
},
|
||||
|
||||
addImageCandidate(candidate) {
|
||||
set((s) => ({ imageCandidates: [...s.imageCandidates, candidate] }));
|
||||
set((s) => {
|
||||
const exists = s.imageCandidates.some((ic) => ic.candidate_id === candidate.candidate_id);
|
||||
return {
|
||||
imageCandidates: exists
|
||||
? s.imageCandidates.map((ic) => ic.candidate_id === candidate.candidate_id ? { ...ic, ...candidate } : ic)
|
||||
: [...s.imageCandidates, candidate],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
markBatchFailed(batch, reason, retryable) {
|
||||
@@ -118,6 +197,7 @@ export const useGenerationStore = create<GenerationState>((set) => ({
|
||||
},
|
||||
|
||||
// 进入已完成/进行中任务时一次性回填(历史任务 SSE 不重放,必须主动拉接口)
|
||||
// 同时把带 is_selected 标记的候选 id 写进 taskStore,修复跨页选中态丢失(B2)
|
||||
hydrateFromTask({ textCandidates, imageCandidates }) {
|
||||
set({
|
||||
textCandidates,
|
||||
@@ -127,14 +207,31 @@ export const useGenerationStore = create<GenerationState>((set) => ({
|
||||
imageDone: imageCandidates.length,
|
||||
imageTotal: imageCandidates.length || 0,
|
||||
});
|
||||
// 若候选带 is_selected 字段,同步还原 taskStore 选中态
|
||||
const selectedTexts = textCandidates
|
||||
.filter((c) => (c as TextCandidate & { is_selected?: boolean }).is_selected)
|
||||
.map((c) => c.candidate_id);
|
||||
const selectedImages = imageCandidates
|
||||
.filter((c) => (c as ImageCandidate & { is_selected?: boolean }).is_selected)
|
||||
.map((c) => c.candidate_id);
|
||||
// 方案B(倩倩姐2026-06-26拍板):选中回填只做「首次进页恢复历史」。
|
||||
// 本地已有选中=用户正在挑(图片/文案选中只存本地、点"去生图/去确认"才提交后端),
|
||||
// 此时 5 秒轮询的后端快照绝不能整体覆盖本地,否则未提交的选中过几秒就被抹掉。
|
||||
const taskState = useTaskStore.getState();
|
||||
const localHasSelection =
|
||||
taskState.selectedTextIds.length > 0 || taskState.selectedImageIds.length > 0;
|
||||
if (!localHasSelection && (selectedTexts.length || selectedImages.length)) {
|
||||
taskState.setSelections(selectedTexts, selectedImages);
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
set({
|
||||
connectionStatus: 'connecting', reconnectAttempt: 0,
|
||||
connectionStatus: 'connecting', reconnectAttempt: 0, stage: 'idle',
|
||||
textDone: 0, textTotal: 0, textCandidates: [],
|
||||
imageDone: 0, imageTotal: 0, imageCandidates: [],
|
||||
failedBatches: [], fatalError: null, flywheelInjected: null,
|
||||
failedBatches: [], fatalError: null, flywheelInjected: null, startedAt: null,
|
||||
textOutcome: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -13,6 +13,7 @@ interface PreferenceState {
|
||||
taskId: number | null;
|
||||
|
||||
fetchContext: (taskId: number) => Promise<void>;
|
||||
fetchContextByProduct: (productId: number) => Promise<void>;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
@@ -34,6 +35,19 @@ export const usePreferenceStore = create<PreferenceState>((set) => ({
|
||||
}
|
||||
},
|
||||
|
||||
async fetchContextByProduct(productId) {
|
||||
// 新建任务页用:此时还没有 task_id,按产品维度取累计偏好
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const data = await api.get<PreferenceContext>(
|
||||
`/api/v1/products/${productId}/preference/context`
|
||||
);
|
||||
set({ context: data, isLoading: false });
|
||||
} catch {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
clear() {
|
||||
set({ context: null, taskId: null });
|
||||
},
|
||||
|
||||
@@ -19,6 +19,9 @@ interface TaskState {
|
||||
setRecentTasks: (tasks: TaskListItem[]) => void;
|
||||
toggleTextSelection: (candidateId: number) => void;
|
||||
toggleImageSelection: (candidateId: number) => void;
|
||||
deselectText: (candidateId: number) => void;
|
||||
deselectImage: (candidateId: number) => void;
|
||||
setSelections: (textIds: number[], imageIds: number[]) => void;
|
||||
clearSelections: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -62,6 +65,19 @@ export const useTaskStore = create<TaskState>((set) => ({
|
||||
set({ selectedTextIds: [], selectedImageIds: [] });
|
||||
},
|
||||
|
||||
deselectText(candidateId) {
|
||||
set((s) => ({ selectedTextIds: s.selectedTextIds.filter((id) => id !== candidateId) }));
|
||||
},
|
||||
|
||||
deselectImage(candidateId) {
|
||||
set((s) => ({ selectedImageIds: s.selectedImageIds.filter((id) => id !== candidateId) }));
|
||||
},
|
||||
|
||||
// 历史任务直接进确认页时,用后端 is_selected 一次性还原已选(store 无持久化,刷新/直达会丢)
|
||||
setSelections(textIds, imageIds) {
|
||||
set({ selectedTextIds: textIds, selectedImageIds: imageIds });
|
||||
},
|
||||
|
||||
reset() {
|
||||
set({
|
||||
currentTaskId: null,
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface TextCandidate {
|
||||
verdict?: string; // AI评委总评:"优秀"|"合格"|"不合格",旧数据为空字符串
|
||||
summary?: string; // AI评委一句话总评(含改进点),旧数据为空字符串
|
||||
edited?: boolean; // 是否被运营改过稿(D1 飞轮最强信号)
|
||||
is_selected?: boolean; // 后端落库的已选标记;历史任务直达确认页时据此还原已选
|
||||
}
|
||||
|
||||
export interface SelectCandidateRequest { candidate_id: number; }
|
||||
@@ -89,8 +90,27 @@ export interface ReviewQueueItem {
|
||||
theme: string;
|
||||
submitted_at: string;
|
||||
operator_name: string;
|
||||
selected_text: { candidate_id: number; angle_label: string; title?: string; content: string; tags?: string[]; score?: TextScore | null; };
|
||||
selected_image: { candidate_id: number; strategy: 'A' | 'B' | 'C'; url: string; };
|
||||
selected_text: { candidate_id: number; angle_label: string; title?: string; content: string; tags?: string[]; score?: TextScore | null; } | null;
|
||||
selected_image: { candidate_id: number; strategy: 'A' | 'B' | 'C'; url: string; } | null;
|
||||
selected_sets?: Array<{
|
||||
strategy: 'A' | 'B' | 'C';
|
||||
selected_text: {
|
||||
candidate_id: number;
|
||||
strategy?: 'A' | 'B' | 'C';
|
||||
angle_label: string;
|
||||
title?: string;
|
||||
content: string;
|
||||
tags?: string[];
|
||||
score?: TextScore | null;
|
||||
} | null;
|
||||
selected_images: Array<{
|
||||
candidate_id: number;
|
||||
strategy: 'A' | 'B' | 'C';
|
||||
url: string;
|
||||
role?: string;
|
||||
seq?: number;
|
||||
} | null>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RejectRequest { reason: string; }
|
||||
@@ -100,6 +120,7 @@ export interface PreferenceContext {
|
||||
recent_preference: string;
|
||||
reject_reasons: string[];
|
||||
injected_count: number;
|
||||
signal_count?: number; // 该产品累计信号总数("飞轮已积累N条信号")
|
||||
}
|
||||
|
||||
// 交付包
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface User {
|
||||
email: string;
|
||||
current_workspace_id: number;
|
||||
role: UserRole;
|
||||
must_change_password?: boolean;
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
@@ -59,6 +60,8 @@ export interface Product {
|
||||
banned_word_ids: number[];
|
||||
image_path: string | null; // 产品参考图路径(铁律:生图带产品图,向后兼容=主图)
|
||||
images?: ProductImage[]; // R5多图:每张带 scene 场景类型
|
||||
brand_keyword?: string | null; // 012:套2字段 品牌词,客户录入
|
||||
target_audience?: string | null; // 012:套2字段 目标人群
|
||||
}
|
||||
|
||||
export interface CreateProductRequest {
|
||||
@@ -70,20 +73,24 @@ export interface CreateProductRequest {
|
||||
text_angles: string[];
|
||||
custom_prompt: string | null;
|
||||
banned_word_ids: number[];
|
||||
brand_keyword?: string | null; // 012:套2字段 品牌词,新建/编辑均可填
|
||||
target_audience?: string | null; // 012:套2字段 目标人群,新建/编辑均可填
|
||||
}
|
||||
|
||||
// 标杆笔记
|
||||
export interface Benchmark {
|
||||
id: number;
|
||||
screenshot_url: string;
|
||||
screenshot_url: string | null;
|
||||
highlights: string;
|
||||
link_url: string | null;
|
||||
analyze_status?: 'pending' | 'analyzing' | 'done' | 'failed';
|
||||
features?: Record<string, unknown> | null;
|
||||
analysis_source?: string | null;
|
||||
analysis_warning?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateBenchmarkRequest {
|
||||
screenshot_url: string;
|
||||
screenshot_url?: string | null;
|
||||
highlights: string;
|
||||
link_url: string | null;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,12 @@ export interface ImageCandidateEvent extends SseBaseEvent {
|
||||
|
||||
export interface FlywheelInjectedEvent extends SseBaseEvent {
|
||||
type: 'flywheel_injected';
|
||||
data: { recent_preference: string; reject_reasons: string[] };
|
||||
data: {
|
||||
recent_preference: string;
|
||||
reject_reasons: string[];
|
||||
injected_count?: number; // 本次注入的有效信号数
|
||||
signal_count?: number; // 该产品累计信号总数("飞轮已积累N条")
|
||||
};
|
||||
}
|
||||
|
||||
export interface BatchFailedEvent extends SseBaseEvent {
|
||||
@@ -53,7 +58,13 @@ export interface BatchFailedEvent extends SseBaseEvent {
|
||||
|
||||
export interface TaskDoneEvent extends SseBaseEvent {
|
||||
type: 'task_done';
|
||||
data: { task_id: number; status: string };
|
||||
data: {
|
||||
task_id: number; status: string;
|
||||
// B1:文案产出结果,前端据此区分"评分不可用/质量不合格/补充中/正常"
|
||||
text_saved?: number;
|
||||
text_target?: number;
|
||||
text_reason?: 'scoring_unavailable' | 'generation_failed' | 'quality_filtered' | 'replenishing' | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SseErrorEvent extends SseBaseEvent {
|
||||
|
||||
@@ -47,6 +47,15 @@ module.exports = {
|
||||
minWidth: {
|
||||
'320': '1280px',
|
||||
},
|
||||
keyframes: {
|
||||
shimmer: {
|
||||
'0%': { backgroundPosition: '200% 0' },
|
||||
'100%': { backgroundPosition: '-200% 0' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
shimmer: 'shimmer 1.5s infinite linear',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -11,13 +15,27 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user