Merge branch 'dhk-1' into 'master'
feat(task): 任务详情(cop-task-detail)登录深链增加本地假数据开关 See merge request knowledge-base-bj/deerflow-server!130
This commit is contained in:
commit
1fa2338ac3
16 files changed
+489
-263
No files matched your search
@@ -84,7 +84,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
|||||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||||
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
|
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
|
||||||
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalysSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`.
|
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
|
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
|
||||||
VITE_USE_REAL_DATA=true
|
VITE_USE_REAL_DATA=true
|
||||||
|
|
||||||
|
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(consumer 外网不可达时调试用)。
|
||||||
|
VITE_MOCK_COP_TASK_DETAIL=false
|
||||||
|
|
||||||
# Gateway / DeerFlow API
|
# Gateway / DeerFlow API
|
||||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
||||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||||
|
|||||||
@@ -33,6 +33,11 @@
|
|||||||
# 仅本机 .env 默认 false(假数据)。改后需重启 dev / 重新打包生效。
|
# 仅本机 .env 默认 false(假数据)。改后需重启 dev / 重新打包生效。
|
||||||
# VITE_USE_REAL_DATA=true
|
# VITE_USE_REAL_DATA=true
|
||||||
|
|
||||||
|
# --- 任务详情(登录深链 cop-task-detail)数据源开关 ---
|
||||||
|
# 默认真实接口(未设/false);改成 true 时该接口走本地假数据(consumer 外网不可达时调试用)。
|
||||||
|
# 与 VITE_USE_REAL_DATA 独立。改后需重启 dev / 重新打包生效。
|
||||||
|
# VITE_MOCK_COP_TASK_DETAIL=false
|
||||||
|
|
||||||
# MaxKey 鉴权根地址(无界 onlineTicket 换 token)
|
# MaxKey 鉴权根地址(无界 onlineTicket 换 token)
|
||||||
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||||
# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
|
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
|
||||||
VITE_USE_REAL_DATA=true
|
VITE_USE_REAL_DATA=true
|
||||||
|
|
||||||
|
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
|
||||||
|
VITE_MOCK_COP_TASK_DETAIL=false
|
||||||
|
|
||||||
# Gateway / DeerFlow API
|
# Gateway / DeerFlow API
|
||||||
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
|
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
|
||||||
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
|
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
|
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
|
||||||
VITE_USE_REAL_DATA=true
|
VITE_USE_REAL_DATA=true
|
||||||
|
|
||||||
|
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
|
||||||
|
VITE_MOCK_COP_TASK_DETAIL=false
|
||||||
|
|
||||||
# Gateway / DeerFlow API
|
# Gateway / DeerFlow API
|
||||||
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
||||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
|
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
|
||||||
VITE_USE_REAL_DATA=true
|
VITE_USE_REAL_DATA=true
|
||||||
|
|
||||||
|
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
|
||||||
|
VITE_MOCK_COP_TASK_DETAIL=false
|
||||||
|
|
||||||
# Gateway / DeerFlow API
|
# Gateway / DeerFlow API
|
||||||
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
|
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
|
||||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
|
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -96,7 +97,9 @@ const MORE_CHATS_HREF = "/page/workspace/chats";
|
|||||||
const MAX_FREQUENT = 4;
|
const MAX_FREQUENT = 4;
|
||||||
|
|
||||||
// 智能体图标统一用 Bot 机器人图标;底色按 id 稳定散列到一组主题色,复刻参考图的彩色方块。
|
// 智能体图标统一用 Bot 机器人图标;底色按 id 稳定散列到一组主题色,复刻参考图的彩色方块。
|
||||||
const AGENT_COLORS = [
|
// 浅色模式沿用原高彩度彩色(不改);深蓝主题改用《深蓝 UI 规范》§1.1.7/§1.1.8 的蓝-青系
|
||||||
|
// (docs/ui规范-深蓝,克制、不刺眼),仅深蓝主题下生效,互不影响。
|
||||||
|
const AGENT_COLORS_LIGHT = [
|
||||||
{ from: "#fb7185", to: "#ef4444" },
|
{ from: "#fb7185", to: "#ef4444" },
|
||||||
{ from: "#60a5fa", to: "#3b82f6" },
|
{ from: "#60a5fa", to: "#3b82f6" },
|
||||||
{ from: "#fbbf24", to: "#f59e0b" },
|
{ from: "#fbbf24", to: "#f59e0b" },
|
||||||
@@ -104,12 +107,21 @@ const AGENT_COLORS = [
|
|||||||
{ from: "#a78bfa", to: "#8b5cf6" },
|
{ from: "#a78bfa", to: "#8b5cf6" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function agentColor(id: string) {
|
const AGENT_COLORS_DARK = [
|
||||||
|
{ from: "#4096FF", to: "#1677FF" }, // 蓝(链接蓝 → 主色)
|
||||||
|
{ from: "#36CFC9", to: "#13C2C2" }, // 青(辅色)
|
||||||
|
{ from: "#6388FF", to: "#0958D9" }, // 紫蓝(装饰紫 → 主色深一档)
|
||||||
|
{ from: "#5CDBD3", to: "#08979C" }, // 浅青 → 深青
|
||||||
|
{ from: "#69B1FF", to: "#003EB3" }, // 浅蓝 → 主色按下
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function agentColor(id: string, isDark: boolean) {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < id.length; i++) {
|
for (let i = 0; i < id.length; i++) {
|
||||||
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
}
|
}
|
||||||
return AGENT_COLORS[hash % AGENT_COLORS.length]!;
|
const palette = isDark ? AGENT_COLORS_DARK : AGENT_COLORS_LIGHT;
|
||||||
|
return palette[hash % palette.length]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 折叠区块标题(带 chevron 与可选计数徽标)。 */
|
/** 折叠区块标题(带 chevron 与可选计数徽标)。 */
|
||||||
@@ -150,6 +162,9 @@ function SectionLabel({
|
|||||||
|
|
||||||
export function GeneralQaSidebar() {
|
export function GeneralQaSidebar() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isDark = theme === "dark";
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -309,12 +324,14 @@ export function GeneralQaSidebar() {
|
|||||||
const isStatic = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
const isStatic = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-white">
|
// 背景走 sidebar token(浅主题=白、深蓝主题=深蓝),不要写死 bg-white,否则全量版深蓝主题下侧栏仍是白底。
|
||||||
|
<div className="flex h-full min-h-0 flex-col bg-sidebar text-sidebar-foreground">
|
||||||
{/* ── 顶部:新对话胶囊 + 收起按钮 ── */}
|
{/* ── 顶部:新对话胶囊 + 收起按钮 ── */}
|
||||||
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
|
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
|
||||||
{/* 新对话按钮:完全自定义样式(不复用 Button variant)。borderColor / color 走
|
{/* 新对话按钮:完全自定义样式(不复用 Button variant)。borderColor / color 走
|
||||||
inline style,避免被全局 border-color(--border)覆盖;无阴影。浅主色底 + 主色描边
|
inline style,避免被全局 border-color(--border)覆盖;无阴影。主色描边 + 5px 圆角。
|
||||||
+ 5px 圆角,全部走 --primary token,自动适配红/蓝/深蓝主题。 */}
|
底色用「主色混入透明」=主色低透明度叠在当前侧栏底色上,浅/红/蓝/深蓝主题都自适应
|
||||||
|
(不要混 white,否则深蓝主题下会变成浅色块)。 */}
|
||||||
<Link
|
<Link
|
||||||
href={NEW_CHAT_PATH}
|
href={NEW_CHAT_PATH}
|
||||||
onClick={handleNewChatClick}
|
onClick={handleNewChatClick}
|
||||||
@@ -322,7 +339,7 @@ export function GeneralQaSidebar() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[5px] text-[14px] font-semibold transition-colors",
|
"flex h-10 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[5px] text-[14px] font-semibold transition-colors",
|
||||||
"border border-solid",
|
"border border-solid",
|
||||||
"bg-[color-mix(in_srgb,var(--primary)_12%,white)] hover:bg-[color-mix(in_srgb,var(--primary)_20%,white)]",
|
"bg-[color-mix(in_srgb,var(--primary)_14%,transparent)] hover:bg-[color-mix(in_srgb,var(--primary)_24%,transparent)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Plus className="size-4 stroke-[2.5]" />
|
<Plus className="size-4 stroke-[2.5]" />
|
||||||
@@ -344,7 +361,7 @@ export function GeneralQaSidebar() {
|
|||||||
<div className="mt-1 flex flex-col gap-1">
|
<div className="mt-1 flex flex-col gap-1">
|
||||||
{frequent.map((agent) => {
|
{frequent.map((agent) => {
|
||||||
const isActive = activeAgentId === agent.id;
|
const isActive = activeAgentId === agent.id;
|
||||||
const { from, to } = agentColor(agent.id);
|
const { from, to } = agentColor(agent.id, isDark);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* 拉取任务详情并据 goPath 把用户直接送到对应的智能体问答 / 多智能体会商页。
|
* 拉取任务详情并据 goPath 把用户直接送到对应的智能体问答 / 多智能体会商页。
|
||||||
*
|
*
|
||||||
* 流程:
|
* 流程:
|
||||||
* 1. 调 consumer 接口 ``GET {VITE_AIPI_CONSUMER_URL}/taskAnalysSearch/cop-task-detail?taskId=``,
|
* 1. 调 consumer 接口 ``GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=``,
|
||||||
* 请求头 ``Authorization: Bearer admin``(由 service 拦截器统一注入)。
|
* 请求头 ``Authorization: Bearer admin``(由 service 拦截器统一注入)。
|
||||||
* 2. 把返回的 data(id/overview/taskDirection/taskName/taskContent)整包存到 sessionStorage。
|
* 2. 把返回的 data(id/overview/taskDirection/taskName/taskContent)整包存到 sessionStorage。
|
||||||
* 3. 按 goPath 解析出业务码 + 模式:
|
* 3. 按 goPath 解析出业务码 + 模式:
|
||||||
@@ -74,7 +74,7 @@ export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail
|
|||||||
const base = consumerBaseUrl();
|
const base = consumerBaseUrl();
|
||||||
if (!base || !taskId) return null;
|
if (!base || !taskId) return null;
|
||||||
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
|
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
|
||||||
const body = await requestCrudService.get(`${base}/taskAnalysSearch/cop-task-detail`, { taskId });
|
const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId });
|
||||||
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
|
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
|
||||||
if (!data || typeof data !== "object") return null;
|
if (!data || typeof data !== "object") return null;
|
||||||
const d = data as Record<string, unknown>;
|
const d = data as Record<string, unknown>;
|
||||||
|
|||||||
@@ -249,13 +249,6 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
path: "/page/strategy/admin/positions",
|
path: "/page/strategy/admin/positions",
|
||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "mgmt-user-leaderboard",
|
|
||||||
label: "用户排行榜",
|
|
||||||
icon: Trophy,
|
|
||||||
path: "/page/strategy/admin/user-leaderboard",
|
|
||||||
adminOnly: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "mgmt-admin-scheduled-tasks",
|
id: "mgmt-admin-scheduled-tasks",
|
||||||
label: "定时任务管理",
|
label: "定时任务管理",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { uuid } from '@/core/utils/uuid';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||||
import { useWritingForm } from '../../contexts/WritingFormContext';
|
import { useWritingFormOptional } from '../../contexts/WritingFormContext';
|
||||||
import { WritingWelcome } from './WritingWelcome';
|
import { WritingWelcome } from './WritingWelcome';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,13 +42,25 @@ import { WritingWelcome } from './WritingWelcome';
|
|||||||
*
|
*
|
||||||
* 这条线程只负责「帮用户把表单聊清」,与真正写作的 ai_writing graph 完全解耦。
|
* 这条线程只负责「帮用户把表单聊清」,与真正写作的 ai_writing graph 完全解耦。
|
||||||
*/
|
*/
|
||||||
export function WritingSetupChat() {
|
export function WritingSetupChat({
|
||||||
|
onApprove: onApproveProp,
|
||||||
|
title = '写作助手',
|
||||||
|
hint = '描述你想写什么,我来帮你填好右侧配置',
|
||||||
|
}: {
|
||||||
|
/** 审批卡「应用」回调;缺省回填写作表单(需 WritingFormProvider)。传入则脱离表单上下文,
|
||||||
|
* 用于复用到「新建研究模板」等场景,把建议字段写进调用方的表单。 */
|
||||||
|
onApprove?: (payload: SetupWritingApprovalPayload) => void;
|
||||||
|
title?: string;
|
||||||
|
hint?: string;
|
||||||
|
} = {}) {
|
||||||
const threadId = useMemo(() => uuid(), []);
|
const threadId = useMemo(() => uuid(), []);
|
||||||
const [threadPrepared, setThreadPrepared] = useState(false);
|
const [threadPrepared, setThreadPrepared] = useState(false);
|
||||||
const [chatModel, setChatModel] = useState('');
|
const [chatModel, setChatModel] = useState('');
|
||||||
const { models, isLoading: modelsLoading } = useModels();
|
const { models, isLoading: modelsLoading } = useModels();
|
||||||
const { articleTypes } = useArticleTypes();
|
const { articleTypes } = useArticleTypes();
|
||||||
const { applyProposal } = useWritingForm();
|
// 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。
|
||||||
|
const formCtx = useWritingFormOptional();
|
||||||
|
const applyProposal = onApproveProp ?? formCtx?.applyProposal;
|
||||||
|
|
||||||
// 首个模型可用时默认选中
|
// 首个模型可用时默认选中
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,8 +110,8 @@ export function WritingSetupChat() {
|
|||||||
|
|
||||||
const handleApprove = useCallback(
|
const handleApprove = useCallback(
|
||||||
async (payload: SetupWritingApprovalPayload) => {
|
async (payload: SetupWritingApprovalPayload) => {
|
||||||
applyProposal(payload);
|
applyProposal?.(payload);
|
||||||
toast.success('已应用到左侧表单,可继续编辑或开始写作');
|
toast.success('已应用到表单,可继续编辑');
|
||||||
},
|
},
|
||||||
[applyProposal],
|
[applyProposal],
|
||||||
);
|
);
|
||||||
@@ -119,8 +131,8 @@ export function WritingSetupChat() {
|
|||||||
{!showWelcome && (
|
{!showWelcome && (
|
||||||
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
||||||
<Sparkles className="size-4 text-primary" />
|
<Sparkles className="size-4 text-primary" />
|
||||||
<h2 className="text-sm font-semibold">写作助手</h2>
|
<h2 className="text-sm font-semibold">{title}</h2>
|
||||||
<span className="text-muted-foreground text-xs">描述你想写什么,我来帮你填好右侧配置</span>
|
<span className="text-muted-foreground text-xs">{hint}</span>
|
||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function WritingWelcome() {
|
|||||||
<div className="flex flex-1 flex-col justify-center gap-6">
|
<div className="flex flex-1 flex-col justify-center gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
|
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
|
||||||
欢迎使用「妙笔 AI」智力打字协作工坊
|
欢迎使用「ai写作」智力打字协作工坊
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
|
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
|
||||||
这是一个专业的自动化写作系统。你只需在下方对话框中
|
这是一个专业的自动化写作系统。你只需在下方对话框中
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import type { SetupWritingApprovalPayload } from '@/components/workspace/messages';
|
||||||
|
|
||||||
|
import { useResearchTemplates } from '../hooks/useResearchTemplates';
|
||||||
|
import { useArticleTypes } from '../hooks/useArticleTypes';
|
||||||
|
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
|
||||||
|
import type {
|
||||||
|
CreateResearchTemplateRequest,
|
||||||
|
ResearchTemplate,
|
||||||
|
} from '../api/ai-writing-research-templates';
|
||||||
|
|
||||||
|
const LIST_PATH = '/page/canvas/research-templates';
|
||||||
|
|
||||||
|
const INPUT_CLASS =
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm ' +
|
||||||
|
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50';
|
||||||
|
const TEXTAREA_CLASS =
|
||||||
|
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ' +
|
||||||
|
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none';
|
||||||
|
const LABEL_CLASS = 'mb-1 block text-xs font-medium text-muted-foreground';
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
article_type_key: string;
|
||||||
|
outline: string;
|
||||||
|
material_source: 'general' | 'notebook';
|
||||||
|
word_count: string;
|
||||||
|
audience: string;
|
||||||
|
strict_mode: boolean;
|
||||||
|
topic: string;
|
||||||
|
sort_order: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: FormState = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
article_type_key: '',
|
||||||
|
outline: '',
|
||||||
|
material_source: 'general',
|
||||||
|
word_count: '800',
|
||||||
|
audience: '',
|
||||||
|
strict_mode: false,
|
||||||
|
topic: '',
|
||||||
|
sort_order: '0',
|
||||||
|
};
|
||||||
|
|
||||||
|
function toForm(t: ResearchTemplate): FormState {
|
||||||
|
return {
|
||||||
|
name: t.name,
|
||||||
|
description: t.description ?? '',
|
||||||
|
article_type_key: t.article_type_key ?? '',
|
||||||
|
outline: t.outline ?? '',
|
||||||
|
material_source: t.material_source,
|
||||||
|
word_count: String(t.word_count),
|
||||||
|
audience: t.audience ?? '',
|
||||||
|
strict_mode: t.strict_mode,
|
||||||
|
topic: t.topic ?? '',
|
||||||
|
sort_order: String(t.sort_order),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPayload(form: FormState): CreateResearchTemplateRequest {
|
||||||
|
return {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim() || null,
|
||||||
|
article_type_key: form.article_type_key || null,
|
||||||
|
outline: form.outline.trim() || null,
|
||||||
|
material_source: form.material_source,
|
||||||
|
word_count: Math.max(100, parseInt(form.word_count, 10) || 800),
|
||||||
|
audience: form.audience.trim() || null,
|
||||||
|
strict_mode: form.strict_mode,
|
||||||
|
topic: form.topic.trim() || null,
|
||||||
|
sort_order: parseInt(form.sort_order, 10) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResearchTemplateEditorPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const editing = !!id;
|
||||||
|
|
||||||
|
const { templates, refresh, create, update } = useResearchTemplates();
|
||||||
|
const { articleTypes, refresh: refreshTypes } = useArticleTypes();
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||||
|
// 新建即就绪;编辑需等列表加载后回填一次。
|
||||||
|
const [initialized, setInitialized] = useState(!editing);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
void refreshTypes();
|
||||||
|
}, [refresh, refreshTypes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing || initialized) return;
|
||||||
|
const t = templates.find((x) => x.id === id);
|
||||||
|
if (t) {
|
||||||
|
setForm(toForm(t));
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
}, [editing, initialized, templates, id]);
|
||||||
|
|
||||||
|
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||||
|
setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
|
// 问答助手「应用」→ 把 setup_writing 建议映射进模板表单字段(与 AI 写作同款机制)。
|
||||||
|
const applyProposalToForm = (p: SetupWritingApprovalPayload) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
name: f.name || (p.user_intent?.trim() ? p.user_intent.trim().slice(0, 20) : f.name),
|
||||||
|
topic: p.user_intent?.trim() || f.topic,
|
||||||
|
article_type_key: p.article_type?.trim() || f.article_type_key,
|
||||||
|
outline: p.outline?.trim() || f.outline,
|
||||||
|
word_count:
|
||||||
|
typeof p.word_count_target === 'number' && p.word_count_target > 0
|
||||||
|
? String(p.word_count_target)
|
||||||
|
: f.word_count,
|
||||||
|
audience: p.target_audience?.trim() || f.audience,
|
||||||
|
strict_mode:
|
||||||
|
p.writing_mode === 'strict' ? true : p.writing_mode === 'loose' ? false : f.strict_mode,
|
||||||
|
material_source: p.material_source
|
||||||
|
? p.material_source === 'notebook'
|
||||||
|
? 'notebook'
|
||||||
|
: 'general'
|
||||||
|
: f.material_source,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
setFormError('请填写模板名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setFormError(null);
|
||||||
|
try {
|
||||||
|
const payload = toPayload(form);
|
||||||
|
if (editing && id) await update(id, payload);
|
||||||
|
else await create(payload);
|
||||||
|
navigate(LIST_PATH);
|
||||||
|
} catch (e) {
|
||||||
|
setFormError(e instanceof Error ? e.message : '保存失败');
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col bg-background">
|
||||||
|
<header className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon-sm" onClick={() => navigate(LIST_PATH)}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-base font-semibold">
|
||||||
|
{editing ? '编辑研究模板' : '新建研究模板'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
左侧用问答描述需求,自动填好右侧字段;也可手动编辑。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{formError && <span className="text-sm text-destructive">{formError}</span>}
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate(LIST_PATH)} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => void handleSave()} disabled={saving}>
|
||||||
|
{saving && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{/* 左:模板助手问答(同 AI 写作 setup 机制)→ 应用到右侧表单。问答区更宽(约 7:3)。 */}
|
||||||
|
<div className="min-w-0 flex-[7] border-r border-border">
|
||||||
|
<WritingSetupChat
|
||||||
|
onApprove={applyProposalToForm}
|
||||||
|
title="模板助手"
|
||||||
|
hint="描述你想要的研究模板,我来帮你填好右侧字段"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右:模板表单 */}
|
||||||
|
<div className="grid min-w-0 flex-[3] grid-cols-2 content-start gap-4 overflow-y-auto p-6">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className={LABEL_CLASS}>模板名称 *</label>
|
||||||
|
<input
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => set('name', e.target.value)}
|
||||||
|
placeholder="如:深度舆情分析"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className={LABEL_CLASS}>描述</label>
|
||||||
|
<input
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => set('description', e.target.value)}
|
||||||
|
placeholder="模板用途简述"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>文章类型</label>
|
||||||
|
<select
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.article_type_key}
|
||||||
|
onChange={(e) => set('article_type_key', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">不设</option>
|
||||||
|
{articleTypes.map((t) => (
|
||||||
|
<option key={t.key} value={t.key}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>搜索来源</label>
|
||||||
|
<select
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.material_source}
|
||||||
|
onChange={(e) => set('material_source', e.target.value as 'general' | 'notebook')}
|
||||||
|
>
|
||||||
|
<option value="general">通用检索</option>
|
||||||
|
<option value="notebook">我的空间(笔记本)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>目标字数</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.word_count}
|
||||||
|
onChange={(e) => set('word_count', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>目标受众</label>
|
||||||
|
<input
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.audience}
|
||||||
|
onChange={(e) => set('audience', e.target.value)}
|
||||||
|
placeholder="如:决策者"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className={LABEL_CLASS}>主题 / 意图</label>
|
||||||
|
<textarea
|
||||||
|
className={TEXTAREA_CLASS}
|
||||||
|
rows={2}
|
||||||
|
value={form.topic}
|
||||||
|
onChange={(e) => set('topic', e.target.value)}
|
||||||
|
placeholder="点击模板时预填到写作意图输入框"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className={LABEL_CLASS}>默认大纲</label>
|
||||||
|
<textarea
|
||||||
|
className={TEXTAREA_CLASS}
|
||||||
|
rows={6}
|
||||||
|
value={form.outline}
|
||||||
|
onChange={(e) => set('outline', e.target.value)}
|
||||||
|
placeholder={'一、…\n二、…\n三、…'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="rt-strict"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.strict_mode}
|
||||||
|
onChange={(e) => set('strict_mode', e.target.checked)}
|
||||||
|
className="size-4"
|
||||||
|
/>
|
||||||
|
<label htmlFor="rt-strict" className="text-sm">
|
||||||
|
严格写作模式
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>排序(小在前)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.sort_order}
|
||||||
|
onChange={(e) => set('sort_order', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,86 +13,15 @@ import {
|
|||||||
|
|
||||||
import { useResearchTemplates } from '../hooks/useResearchTemplates';
|
import { useResearchTemplates } from '../hooks/useResearchTemplates';
|
||||||
import { useArticleTypes } from '../hooks/useArticleTypes';
|
import { useArticleTypes } from '../hooks/useArticleTypes';
|
||||||
import type {
|
|
||||||
CreateResearchTemplateRequest,
|
|
||||||
ResearchTemplate,
|
|
||||||
} from '../api/ai-writing-research-templates';
|
|
||||||
|
|
||||||
const INPUT_CLASS =
|
const NEW_PATH = '/page/canvas/research-templates/new';
|
||||||
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm ' +
|
const editPath = (id: string) => `/page/canvas/research-templates/edit/${id}`;
|
||||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50';
|
|
||||||
const TEXTAREA_CLASS =
|
|
||||||
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ' +
|
|
||||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none';
|
|
||||||
const LABEL_CLASS = 'mb-1 block text-xs font-medium text-muted-foreground';
|
|
||||||
|
|
||||||
interface FormState {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
article_type_key: string;
|
|
||||||
outline: string;
|
|
||||||
material_source: 'general' | 'notebook';
|
|
||||||
word_count: string;
|
|
||||||
audience: string;
|
|
||||||
strict_mode: boolean;
|
|
||||||
topic: string;
|
|
||||||
sort_order: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_FORM: FormState = {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
article_type_key: '',
|
|
||||||
outline: '',
|
|
||||||
material_source: 'general',
|
|
||||||
word_count: '800',
|
|
||||||
audience: '',
|
|
||||||
strict_mode: false,
|
|
||||||
topic: '',
|
|
||||||
sort_order: '0',
|
|
||||||
};
|
|
||||||
|
|
||||||
function toForm(t: ResearchTemplate): FormState {
|
|
||||||
return {
|
|
||||||
name: t.name,
|
|
||||||
description: t.description ?? '',
|
|
||||||
article_type_key: t.article_type_key ?? '',
|
|
||||||
outline: t.outline ?? '',
|
|
||||||
material_source: t.material_source,
|
|
||||||
word_count: String(t.word_count),
|
|
||||||
audience: t.audience ?? '',
|
|
||||||
strict_mode: t.strict_mode,
|
|
||||||
topic: t.topic ?? '',
|
|
||||||
sort_order: String(t.sort_order),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toPayload(form: FormState): CreateResearchTemplateRequest {
|
|
||||||
return {
|
|
||||||
name: form.name.trim(),
|
|
||||||
description: form.description.trim() || null,
|
|
||||||
article_type_key: form.article_type_key || null,
|
|
||||||
outline: form.outline.trim() || null,
|
|
||||||
material_source: form.material_source,
|
|
||||||
word_count: Math.max(100, parseInt(form.word_count, 10) || 800),
|
|
||||||
audience: form.audience.trim() || null,
|
|
||||||
strict_mode: form.strict_mode,
|
|
||||||
topic: form.topic.trim() || null,
|
|
||||||
sort_order: parseInt(form.sort_order, 10) || 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ResearchTemplatesConfigPage() {
|
export default function ResearchTemplatesConfigPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { templates, loading, error, refresh, create, update, remove } = useResearchTemplates();
|
const { templates, loading, error, refresh, remove } = useResearchTemplates();
|
||||||
const { articleTypes, refresh: refreshTypes } = useArticleTypes();
|
const { articleTypes, refresh: refreshTypes } = useArticleTypes();
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
|
||||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
@@ -101,42 +30,6 @@ export default function ResearchTemplatesConfigPage() {
|
|||||||
void refreshTypes();
|
void refreshTypes();
|
||||||
}, [refresh, refreshTypes]);
|
}, [refresh, refreshTypes]);
|
||||||
|
|
||||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
|
||||||
setForm((f) => ({ ...f, [key]: value }));
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
setEditingId(null);
|
|
||||||
setForm(EMPTY_FORM);
|
|
||||||
setFormError(null);
|
|
||||||
setDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (t: ResearchTemplate) => {
|
|
||||||
setEditingId(t.id);
|
|
||||||
setForm(toForm(t));
|
|
||||||
setFormError(null);
|
|
||||||
setDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!form.name.trim()) {
|
|
||||||
setFormError('请填写模板名称');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
setFormError(null);
|
|
||||||
try {
|
|
||||||
const payload = toPayload(form);
|
|
||||||
if (editingId) await update(editingId, payload);
|
|
||||||
else await create(payload);
|
|
||||||
setDialogOpen(false);
|
|
||||||
} catch (e) {
|
|
||||||
setFormError(e instanceof Error ? e.message : '保存失败');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteTargetId) return;
|
if (!deleteTargetId) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
@@ -165,7 +58,7 @@ export default function ResearchTemplatesConfigPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={openCreate}>
|
<Button size="sm" onClick={() => navigate(NEW_PATH)}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
新建模板
|
新建模板
|
||||||
</Button>
|
</Button>
|
||||||
@@ -209,7 +102,12 @@ export default function ResearchTemplatesConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(t)} title="编辑">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => navigate(editPath(t.id))}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -227,130 +125,6 @@ export default function ResearchTemplatesConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 新建 / 编辑 弹窗 */}
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={(v) => !saving && setDialogOpen(v)}>
|
|
||||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-[640px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{editingId ? '编辑研究模板' : '新建研究模板'}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid grid-cols-2 gap-4 py-2">
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className={LABEL_CLASS}>模板名称 *</label>
|
|
||||||
<input
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => set('name', e.target.value)}
|
|
||||||
placeholder="如:深度舆情分析"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className={LABEL_CLASS}>描述</label>
|
|
||||||
<input
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
value={form.description}
|
|
||||||
onChange={(e) => set('description', e.target.value)}
|
|
||||||
placeholder="模板用途简述"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={LABEL_CLASS}>文章类型</label>
|
|
||||||
<select
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
value={form.article_type_key}
|
|
||||||
onChange={(e) => set('article_type_key', e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">不设</option>
|
|
||||||
{articleTypes.map((t) => (
|
|
||||||
<option key={t.key} value={t.key}>
|
|
||||||
{t.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={LABEL_CLASS}>搜索来源</label>
|
|
||||||
<select
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
value={form.material_source}
|
|
||||||
onChange={(e) => set('material_source', e.target.value as 'general' | 'notebook')}
|
|
||||||
>
|
|
||||||
<option value="general">通用检索</option>
|
|
||||||
<option value="notebook">我的空间(笔记本)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={LABEL_CLASS}>目标字数</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
value={form.word_count}
|
|
||||||
onChange={(e) => set('word_count', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={LABEL_CLASS}>目标受众</label>
|
|
||||||
<input
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
value={form.audience}
|
|
||||||
onChange={(e) => set('audience', e.target.value)}
|
|
||||||
placeholder="如:决策者"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className={LABEL_CLASS}>主题 / 意图</label>
|
|
||||||
<textarea
|
|
||||||
className={TEXTAREA_CLASS}
|
|
||||||
rows={2}
|
|
||||||
value={form.topic}
|
|
||||||
onChange={(e) => set('topic', e.target.value)}
|
|
||||||
placeholder="点击模板时预填到写作意图输入框"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className={LABEL_CLASS}>默认大纲</label>
|
|
||||||
<textarea
|
|
||||||
className={TEXTAREA_CLASS}
|
|
||||||
rows={4}
|
|
||||||
value={form.outline}
|
|
||||||
onChange={(e) => set('outline', e.target.value)}
|
|
||||||
placeholder={'一、…\n二、…\n三、…'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
id="rt-strict"
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.strict_mode}
|
|
||||||
onChange={(e) => set('strict_mode', e.target.checked)}
|
|
||||||
className="size-4"
|
|
||||||
/>
|
|
||||||
<label htmlFor="rt-strict" className="text-sm">
|
|
||||||
严格写作模式
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={LABEL_CLASS}>排序(小在前)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
value={form.sort_order}
|
|
||||||
onChange={(e) => set('sort_order', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void handleSave()} disabled={saving}>
|
|
||||||
{saving && <Loader2 className="size-4 animate-spin" />}
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* 删除确认 */}
|
{/* 删除确认 */}
|
||||||
<Dialog open={!!deleteTargetId} onOpenChange={(v) => !deleting && !v && setDeleteTargetId(null)}>
|
<Dialog open={!!deleteTargetId} onOpenChange={(v) => !deleting && !v && setDeleteTargetId(null)}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import CanvasThreadPage from "../pages/CanvasThreadPage";
|
|||||||
import AIWritingPage from "../pages/AIWritingPage";
|
import AIWritingPage from "../pages/AIWritingPage";
|
||||||
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
|
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
|
||||||
import ResearchTemplatesConfigPage from "../pages/ResearchTemplatesConfigPage";
|
import ResearchTemplatesConfigPage from "../pages/ResearchTemplatesConfigPage";
|
||||||
|
import ResearchTemplateEditorPage from "../pages/ResearchTemplateEditorPage";
|
||||||
|
|
||||||
export default function OpenCanvasRoutes() {
|
export default function OpenCanvasRoutes() {
|
||||||
return (
|
return (
|
||||||
@@ -13,6 +14,8 @@ export default function OpenCanvasRoutes() {
|
|||||||
<Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} />
|
<Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} />
|
||||||
<Route path="article-types" element={<ArticleTypesConfigPage />} />
|
<Route path="article-types" element={<ArticleTypesConfigPage />} />
|
||||||
<Route path="research-templates" element={<ResearchTemplatesConfigPage />} />
|
<Route path="research-templates" element={<ResearchTemplatesConfigPage />} />
|
||||||
|
<Route path="research-templates/new" element={<ResearchTemplateEditorPage />} />
|
||||||
|
<Route path="research-templates/edit/:id" element={<ResearchTemplateEditorPage />} />
|
||||||
<Route path=":thread_id" element={<CanvasThreadPage />} />
|
<Route path=":thread_id" element={<CanvasThreadPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -399,6 +399,102 @@ const MOCK_ROUTES: Array<{ match: string; dataset: MockDataset }> = [
|
|||||||
{ match: '/task/cop-tasks', dataset: 'task' },
|
{ match: '/task/cop-tasks', dataset: 'task' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ───────────── 任务详情假数据(cop-task-detail,给登录任务深链用) ─────────────
|
||||||
|
//
|
||||||
|
// consumer 接口 `{VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail` 外网可能不可达,
|
||||||
|
// 可用假数据模拟登录深链(?taskId=&goPath=)效果。返回结构与真实接口一致:
|
||||||
|
// `{ code, message, data: { id, overview, taskDirection, taskName, taskContent } }`
|
||||||
|
//
|
||||||
|
// 启停由 `VITE_MOCK_COP_TASK_DETAIL` 控制(默认真实数据,=true 时走假数据),
|
||||||
|
// 见 `utils/request.ts` 的请求拦截器。
|
||||||
|
|
||||||
|
/** cop-task-detail 命中路径。 */
|
||||||
|
const COP_TASK_DETAIL_ROUTE = '/taskAnalyseSearch/cop-task-detail';
|
||||||
|
|
||||||
|
interface CopTaskDetailSeed {
|
||||||
|
taskName: string;
|
||||||
|
taskContent: string;
|
||||||
|
overview: string;
|
||||||
|
taskDirection: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务详情假数据池(按 taskId 取模选一条,让不同 taskId 呈现不同任务)。 */
|
||||||
|
const COP_TASK_DETAIL_SEEDS: CopTaskDetailSeed[] = [
|
||||||
|
{
|
||||||
|
taskName: '暑期文旅消费季舆情倾向分析',
|
||||||
|
taskContent:
|
||||||
|
'围绕"暑期文旅消费季"全网讨论,分析公众情绪走向、核心关切与潜在争议点,识别需重点引导的群体,并给出传播策略建议。',
|
||||||
|
overview: '暑期文旅消费季相关讨论的情绪与倾向分析。',
|
||||||
|
taskDirection: '王磊',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
taskName: '居民阶梯电价调整公众情绪研判',
|
||||||
|
taskContent:
|
||||||
|
'针对居民阶梯电价调整方案,梳理公众主要担忧(用电成本、公平性),判断情绪峰值节点与高敏群体,形成分群体沟通要点。',
|
||||||
|
overview: '阶梯电价调整的公众情绪研判。',
|
||||||
|
taskDirection: '李婷',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
taskName: '城市更新片区舆论引导任务',
|
||||||
|
taskContent:
|
||||||
|
'对老城区改造涉及的拆迁、安置等敏感议题进行情绪与诉求拆解,识别居民、商户等不同立场,制定差异化回应策略。',
|
||||||
|
overview: '城市更新片区敏感议题的舆论引导分析。',
|
||||||
|
taskDirection: '张伟',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 从 taskId 反推一个稳定下标(同一 taskId 每次返回同一条)。 */
|
||||||
|
function pickSeedIndex(taskId: string): number {
|
||||||
|
const digits = (taskId.match(/\d+/g) ?? []).join('');
|
||||||
|
const n = digits ? Number(digits.slice(-6)) : 0;
|
||||||
|
return Number.isFinite(n) ? n % COP_TASK_DETAIL_SEEDS.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模拟 cop-task-detail 的 body。 */
|
||||||
|
function buildCopTaskDetailBody(taskId: string) {
|
||||||
|
const seed = COP_TASK_DETAIL_SEEDS[pickSeedIndex(taskId)];
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
message: 'ok',
|
||||||
|
data: {
|
||||||
|
id: taskId,
|
||||||
|
overview: seed.overview,
|
||||||
|
taskDirection: seed.taskDirection,
|
||||||
|
taskName: seed.taskName,
|
||||||
|
taskContent: seed.taskContent,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断某请求 url 是否是 cop-task-detail。 */
|
||||||
|
export function matchCopTaskDetailMock(url?: string): boolean {
|
||||||
|
return !!url && url.includes(COP_TASK_DETAIL_ROUTE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 axios config 取 taskId(优先 params,其次 url query)。 */
|
||||||
|
function readTaskIdFromConfig(config: { url?: string; params?: Record<string, unknown> }): string {
|
||||||
|
const fromParams = config.params?.taskId;
|
||||||
|
if (fromParams != null && fromParams !== '') return String(fromParams);
|
||||||
|
if (config.url) {
|
||||||
|
const m = config.url.match(/[?&]taskId=([^&]+)/);
|
||||||
|
if (m) return decodeURIComponent(m[1]);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 临时假数据 axios adapter:命中 cop-task-detail 时直接返回 mock body,不发真实请求。 */
|
||||||
|
export function copTaskDetailMockAdapter(config: {
|
||||||
|
url?: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}): Promise<{ data: unknown; status: number; statusText: string; headers: Record<string, unknown>; config: unknown }> {
|
||||||
|
const body = buildCopTaskDetailBody(readTaskIdFromConfig(config));
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve({ data: body, status: 200, statusText: 'OK', headers: {}, config });
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 判断某个请求 url 是否应走假数据。 */
|
/** 判断某个请求 url 是否应走假数据。 */
|
||||||
export function matchTaskBoardMock(url?: string): MockDataset | null {
|
export function matchTaskBoardMock(url?: string): MockDataset | null {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { matchTaskBoardMock, taskBoardMockAdapter } from '@/strategy-components/components/task-components/task-mock-data'
|
import {
|
||||||
|
matchTaskBoardMock,
|
||||||
|
taskBoardMockAdapter,
|
||||||
|
matchCopTaskDetailMock,
|
||||||
|
copTaskDetailMockAdapter,
|
||||||
|
} from '@/strategy-components/components/task-components/task-mock-data'
|
||||||
|
|
||||||
// 任务看板(任务分析 / 三情分析)数据源开关:
|
// 任务看板(任务分析 / 三情分析)数据源开关:
|
||||||
// VITE_USE_REAL_DATA=true → 走真实接口;其它(含未设置)→ 这两个接口走本地假数据。
|
// VITE_USE_REAL_DATA=true → 走真实接口;其它(含未设置)→ 这两个接口走本地假数据。
|
||||||
@@ -7,6 +12,11 @@ import { matchTaskBoardMock, taskBoardMockAdapter } from '@/strategy-components/
|
|||||||
// 仅本机 .env 默认 false(假数据)。改 env 后需重启 dev server 生效。
|
// 仅本机 .env 默认 false(假数据)。改 env 后需重启 dev server 生效。
|
||||||
const USE_REAL_DATA = import.meta.env.VITE_USE_REAL_DATA === 'true'
|
const USE_REAL_DATA = import.meta.env.VITE_USE_REAL_DATA === 'true'
|
||||||
|
|
||||||
|
// 任务详情(cop-task-detail,登录深链用)数据源开关:
|
||||||
|
// 默认真实数据;仅当 VITE_MOCK_COP_TASK_DETAIL=true 时走本地假数据(consumer 外网不可达时调试用)。
|
||||||
|
// 与 USE_REAL_DATA 独立。改 env 后需重启 dev server 生效。
|
||||||
|
const MOCK_COP_TASK_DETAIL = import.meta.env.VITE_MOCK_COP_TASK_DETAIL === 'true'
|
||||||
|
|
||||||
// axios.interceptors
|
// axios.interceptors
|
||||||
const service = axios.create({
|
const service = axios.create({
|
||||||
// baseURL: '', // url = base url + request url
|
// baseURL: '', // url = base url + request url
|
||||||
@@ -43,6 +53,10 @@ service.interceptors.request.use(
|
|||||||
if (!USE_REAL_DATA && matchTaskBoardMock(config.url)) {
|
if (!USE_REAL_DATA && matchTaskBoardMock(config.url)) {
|
||||||
config.adapter = taskBoardMockAdapter as any;
|
config.adapter = taskBoardMockAdapter as any;
|
||||||
}
|
}
|
||||||
|
// 任务详情(登录深链):默认真实接口;仅 VITE_MOCK_COP_TASK_DETAIL=true 时走本地假数据。
|
||||||
|
if (MOCK_COP_TASK_DETAIL && matchCopTaskDetailMock(config.url)) {
|
||||||
|
config.adapter = copTaskDetailMockAdapter as any;
|
||||||
|
}
|
||||||
// let token: any = localStorage.getItem('token');
|
// let token: any = localStorage.getItem('token');
|
||||||
config.headers['Authorization'] = `Bearer admin`;
|
config.headers['Authorization'] = `Bearer admin`;
|
||||||
// if (token) {
|
// if (token) {
|
||||||
|
|||||||
Reference in new issue
Block a user