feat: 拆解单文件 Demo 为 Vite + React 前端工程
- 保留根目录 index.html 作为 v1 单文件原型(不参与构建) - app/ 下新建 Vite + React 工程(vite.config root 指向 app/,两版互不干扰) - 状态收敛到 useAppStore(表单/消息/串行确认/一键发品 + 4 条异步编排流) - 生成卡片重构为结构化数据驱动渲染,按钮为真实 React 事件 - 6 大表单模块卡片化;Agent 意图解析与偏好学习抽为独立 lib - 功能与视觉与 v1 完全一致,浏览器端全流程回归通过
This commit is contained in:
@@ -14,5 +14,8 @@
|
||||
# 本地服务
|
||||
node_modules/
|
||||
|
||||
# 构建产物
|
||||
dist/
|
||||
|
||||
# 本地推送脚本(含本地环境信息,不入库)
|
||||
push-to-gitea.sh
|
||||
|
||||
@@ -2,19 +2,63 @@
|
||||
|
||||
飞猪商家商品发布平台右侧嵌入的 AI 助手交互式演示。模拟完整的商品发布页(六大模块表单)+ 右侧 AI 助手面板,主打「一键发品」与「越用越好用」的偏好学习机制。
|
||||
|
||||
## 快速开始
|
||||
仓库包含两个版本,展示同一个 Demo 从「单文件原型」到「前端工程化」的演进:
|
||||
|
||||
无需构建,直接用浏览器打开:
|
||||
| 版本 | 入口 | 说明 |
|
||||
|---|---|---|
|
||||
| v1 单文件原型 | `index.html` | HTML + CSS + JS 零依赖,浏览器直接打开 |
|
||||
| v2 工程化版 | `app/`(Vite + React) | 组件化拆分、状态收敛、可扩展接入真实 LLM |
|
||||
|
||||
两个版本的功能与视觉完全一致。
|
||||
|
||||
## 快速开始(Vite + React 版)
|
||||
|
||||
```bash
|
||||
open index.html
|
||||
npm install
|
||||
npm run dev # 开发:http://localhost:5173
|
||||
npm run build # 构建产物输出到 dist/
|
||||
npm run preview # 本地预览构建产物
|
||||
```
|
||||
|
||||
或启动本地静态服务:
|
||||
单文件版无需构建:直接 `open index.html`。
|
||||
|
||||
## 工程结构
|
||||
|
||||
```bash
|
||||
npx serve .
|
||||
```
|
||||
├── index.html # v1 单文件 Demo(保留作对照,不参与 Vite 构建)
|
||||
├── vite.config.js # Vite 配置(root 指向 app/,与根目录 index.html 互不干扰)
|
||||
├── app/
|
||||
│ ├── index.html # Vite 入口
|
||||
│ └── src/
|
||||
│ ├── main.jsx # 应用挂载
|
||||
│ ├── App.jsx # 布局组合 + Store 注入
|
||||
│ ├── styles/global.css # 全局样式(自 v1 完整迁移,类名不变)
|
||||
│ ├── data/
|
||||
│ │ └── genData.js # 8 个工具的结构化生成器(文案/卡片/回填数据)
|
||||
│ ├── lib/
|
||||
│ │ ├── agent.js # Agent 意图解析:parseBrief / parseMultiTools / 意图正则
|
||||
│ │ └── oc.js # 偏好学习:localStorage 持久化 + 成长等级
|
||||
│ ├── store/
|
||||
│ │ ├── useAppStore.js # 状态中枢:表单/消息/串行确认/一键发品 + 异步编排流
|
||||
│ │ └── context.js # Store Context
|
||||
│ └── components/
|
||||
│ ├── TopBar.jsx # 顶部导航
|
||||
│ ├── FormArea.jsx # 左侧表单(6 大模块)
|
||||
│ ├── cards/ # 6 个表单模块卡片(Title/Intro/Detail/Image/Itinerary/Price)
|
||||
│ ├── AssistantPanel.jsx # 右侧 AI 助手面板
|
||||
│ ├── MessageItem.jsx / GenCardView.jsx / TaskCardView.jsx # 消息流三种消息
|
||||
│ ├── OneClickModal.jsx # 一键发品弹窗(配置→进度→完成 状态机)
|
||||
│ └── Toast.jsx
|
||||
├── Prompt提示词设计文档.md # 各工具背后的 Prompt 设计规范(可直接落地真实 LLM)
|
||||
└── AI领教营/ # 项目方法论笔记(AI 价值/边界/验证框架)
|
||||
```
|
||||
|
||||
### 架构要点
|
||||
|
||||
- **数据驱动渲染**:v1 中拼接 HTML 字符串的生成卡片,v2 重构为结构化数据(`head / lines / foot / tips`),按钮全部是真实 React 事件
|
||||
- **异步编排与 UI 解耦**:`quickAction / agentRun / multiToolRun / startOC` 四条编排流集中在 store,通过 ref 读取最新状态避免闭包过期
|
||||
- **human-in-the-loop 串行确认**:每个工具生成后 Promise 挂起,等待自动倒计时(5s)或手动确认,支持运行中随时切换模式
|
||||
- **偏好学习持久化**:`ocStore` 写入 localStorage,跨会话预填配置与成长等级
|
||||
|
||||
## 功能概览
|
||||
|
||||
@@ -46,14 +90,6 @@ npx serve .
|
||||
- 即时记忆:每次发品沉淀偏好(localStorage),下次自动预填配置
|
||||
- 成长等级:Lv.1 通用模板(0-2 次)→ Lv.2 风格匹配(3-9 次)→ Lv.3 爆款复刻(10+ 次)
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
├── index.html # 单文件 Demo(HTML + CSS + JS,零依赖)
|
||||
├── Prompt提示词设计文档.md # 各工具背后的 Prompt 设计规范(可直接落地真实 LLM)
|
||||
└── AI领教营/ # 项目方法论笔记(AI 价值/边界/验证框架)
|
||||
```
|
||||
|
||||
## 设计规范
|
||||
|
||||
- **品牌主色**:`#FF7A1A`(飞猪橙黄),渐变 `#FFA200 → #FF6600`
|
||||
@@ -61,11 +97,9 @@ npx serve .
|
||||
|
||||
## 落地指引
|
||||
|
||||
Demo 中的生成逻辑为前端模拟数据(`genData`)。接入真实 LLM 时:
|
||||
Demo 中的生成逻辑为前端模拟数据(`app/src/data/genData.js`)。接入真实 LLM 时:
|
||||
|
||||
1. 将助手面板抽成 Vue/React 独立组件
|
||||
2. 按设计文档定义 API 契约(表单上下文 + 生成类型 → 流式返回)
|
||||
1. 将 `lib/agent.js` 的意图解析替换为 LLM function calling
|
||||
2. 按 `Prompt提示词设计文档.md` 定义 API 契约(表单上下文 + 生成类型 → 流式返回)
|
||||
3. Agent 编排对应后端 Agent Loop:意图分类 → 参数抽取 → function calling → 结果回填 → 偏好沉淀
|
||||
4. 偏好沉淀对应商家画像服务,喂给 LLM 的个性化 prompt
|
||||
|
||||
详见 `Prompt提示词设计文档.md`。
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>商品发布 - 旅智工作台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useAppStore } from './store/useAppStore'
|
||||
import { StoreContext } from './store/context'
|
||||
import TopBar from './components/TopBar'
|
||||
import FormArea from './components/FormArea'
|
||||
import AssistantPanel from './components/AssistantPanel'
|
||||
import OneClickModal from './components/OneClickModal'
|
||||
import Toast from './components/Toast'
|
||||
|
||||
export default function App() {
|
||||
const store = useAppStore()
|
||||
return (
|
||||
<StoreContext.Provider value={store}>
|
||||
<TopBar />
|
||||
<div className="main">
|
||||
<FormArea />
|
||||
<AssistantPanel />
|
||||
</div>
|
||||
<OneClickModal />
|
||||
<Toast />
|
||||
</StoreContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useStore } from '../store/context'
|
||||
import MessageItem from './MessageItem'
|
||||
|
||||
/** 右侧:AI 助手面板(快捷生成 / 消息流 / 对话输入) */
|
||||
export default function AssistantPanel() {
|
||||
const {
|
||||
messages, panelCollapsed, setPanelCollapsed,
|
||||
quickAction, openOC, sendMessage
|
||||
} = useStore()
|
||||
const [input, setInput] = useState('')
|
||||
const msgListRef = useRef(null)
|
||||
|
||||
/* 新消息时自动滚动到底部 */
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const el = msgListRef.current
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
}, [messages])
|
||||
|
||||
const quickItems = [
|
||||
{ type: 'title', icon: 'q1', em: '📝', label: '商品标题' },
|
||||
{ type: 'intro', icon: 'q2', em: '💎', label: '详细介绍' },
|
||||
{ type: 'detail', icon: 'q7', em: '📄', label: '商品详情' },
|
||||
{ type: 'image', icon: 'q5', em: '🖼️', label: '配图建议' },
|
||||
{ type: 'itinerary', icon: 'q3', em: '🗺️', label: '行程规划' },
|
||||
{ type: 'price', icon: 'q4', em: '💰', label: '价格库存' },
|
||||
{ type: 'seo', icon: 'q6', em: '🔍', label: 'SEO 词' },
|
||||
{ type: 'check', icon: 'q6', em: '🛡️', label: '合规检查' }
|
||||
]
|
||||
|
||||
const send = () => {
|
||||
const text = input.trim()
|
||||
if (!text) return
|
||||
setInput('')
|
||||
sendMessage(text)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'ai-panel' + (panelCollapsed ? ' collapsed' : '')}>
|
||||
<button className="ai-float" onClick={() => setPanelCollapsed(!panelCollapsed)}>AI 助手</button>
|
||||
<div className="ai-header">
|
||||
<div className="ai-avatar">🤖</div>
|
||||
<div className="info">
|
||||
<div className="name">旅智小助 <span className="badge">商品发布专精</span></div>
|
||||
<div className="status">在线 · 已读取当前页面表单内容</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="icon-btn" title="收起面板" onClick={() => setPanelCollapsed(!panelCollapsed)}>»</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ai-quick">
|
||||
<div className="title">⚡ 快捷生成(对应左侧 6 大模块)</div>
|
||||
<button className="oc-wide" onClick={openOC}>⚡ 一键发品 · 30 秒完成 6 大模块</button>
|
||||
<div className="quick-grid">
|
||||
{quickItems.map(q => (
|
||||
<button key={q.type} className="quick-btn" onClick={() => quickAction(q.type)}>
|
||||
<span className={'qicon ' + q.icon}>{q.em}</span>
|
||||
<span className="qtext">{q.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ai-messages" ref={msgListRef}>
|
||||
{messages.map(m => <MessageItem key={m.id} msg={m} />)}
|
||||
</div>
|
||||
|
||||
<div className="ai-input-area">
|
||||
<div className="input-wrap">
|
||||
<textarea
|
||||
rows={1}
|
||||
placeholder="例如:帮我优化标题和详情 / 帮我发个海南亲子6天自由行"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="input-foot">
|
||||
<div className="input-tools">
|
||||
<button className="tool-btn" title="参考示例">💡</button>
|
||||
<button className="tool-btn" title="上传图片">🖼️</button>
|
||||
</div>
|
||||
<button className="send-btn" onClick={send}>发送</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="input-hint">Enter 发送 · Shift+Enter 换行 · 内容由 AI 生成,请核对后发布</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useStore } from '../store/context'
|
||||
import TitleCard from './cards/TitleCard'
|
||||
import IntroCard from './cards/IntroCard'
|
||||
import DetailCard from './cards/DetailCard'
|
||||
import ImageCard from './cards/ImageCard'
|
||||
import ItineraryCard from './cards/ItineraryCard'
|
||||
import PriceCard from './cards/PriceCard'
|
||||
|
||||
/** 左侧:商品发布表单(6 大模块卡片) */
|
||||
export default function FormArea() {
|
||||
const { showToast, openOC, sectionRefs, formAreaRef } = useStore()
|
||||
const setRef = id => el => { sectionRefs.current[id] = el }
|
||||
return (
|
||||
<div className="form-area" ref={formAreaRef}>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>发布新商品</h1>
|
||||
<div className="sub">完善商品信息,或用「一键发品」30 秒搞定 · 右侧 AI 助手随时待命 ↗</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button className="btn-ghost-w" onClick={() => showToast('已提交审核(演示)')}>提交审核</button>
|
||||
<button className="btn-magic" onClick={openOC}>⚡ 一键发品</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 每张卡片把根节点注册到 sectionRefs(AI 填入后高亮 + 滚动定位) */}
|
||||
<TitleCard setRef={setRef} />
|
||||
<IntroCard setRef={setRef} />
|
||||
<DetailCard setRef={setRef} />
|
||||
<ImageCard setRef={setRef} />
|
||||
<ItineraryCard setRef={setRef} />
|
||||
<PriceCard setRef={setRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useStore } from '../store/context'
|
||||
|
||||
/** 生成卡片:head / lines / foot / tips,foot 按钮为真实 React 事件 */
|
||||
export default function GenCardView({ gen, confirm }) {
|
||||
const { applyGen, regen, showToast, serialConfirm, serialRegen, countdown } = useStore()
|
||||
const { card } = gen
|
||||
const serial = !!confirm
|
||||
|
||||
const body = (
|
||||
<div className="gen-card">
|
||||
<div className="gc-head">{card.head}</div>
|
||||
<div className="gc-body">
|
||||
{card.lines.map((l, i) => <span key={i} className="line" dangerouslySetInnerHTML={{ __html: l }} />)}
|
||||
</div>
|
||||
{card.foot && (
|
||||
<div className="gc-foot">
|
||||
<button
|
||||
className="gc-btn"
|
||||
onClick={() => card.foot.apply ? applyGen(card.foot.apply) : showToast('SEO 词已复制(演示)')}
|
||||
>{card.foot.label}</button>
|
||||
{card.foot.regen && <button className="gc-btn ghost" onClick={() => regen(gen.type)}>🔄 换一版</button>}
|
||||
</div>
|
||||
)}
|
||||
{card.tips.length > 0 && (
|
||||
<div className="tips-row">
|
||||
{card.tips.map((t, i) => <span key={i} className={'tip-pill ' + t.cls}>{t.text}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
/* 串行流程:外层包 serial-card,底部附确认栏 */
|
||||
if (serial) {
|
||||
return (
|
||||
<div className="serial-card">
|
||||
<div dangerouslySetInnerHTML={{ __html: gen.pre }} />
|
||||
{body}
|
||||
{confirm.status === 'confirmed' ? (
|
||||
<div className="ag-confirm-bar" style={{ opacity: 0.55 }}>
|
||||
<span style={{ color: '#22c55e', fontSize: 12, fontWeight: 600 }}>✓ 已确认,填入表单中…</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ag-confirm-bar">
|
||||
<button className="ag-cf-btn" onClick={serialConfirm}>
|
||||
{card.foot?.apply ? '✅ 确认填入' : '✅ 确认'}
|
||||
</button>
|
||||
<button className="ag-regen-btn" onClick={serialRegen}>🔄 换一版</button>
|
||||
{confirm.mode
|
||||
? <span className="ag-cd">{countdown}s 后自动确认</span>
|
||||
: <span className="ag-cd manual">⏸ 等待您确认…</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div dangerouslySetInnerHTML={{ __html: gen.pre }} />
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import GenCardView from './GenCardView'
|
||||
import TaskCardView from './TaskCardView'
|
||||
|
||||
/** 单条消息:typing 占位 / 富文本 / 生成卡片 / Agent 任务清单 */
|
||||
export default function MessageItem({ msg }) {
|
||||
const role = msg.role
|
||||
return (
|
||||
<div className={'msg ' + role}>
|
||||
<div className="m-avatar">{role === 'ai' ? '🤖' : '李'}</div>
|
||||
<div className="bubble">
|
||||
{msg.kind === 'typing' && (
|
||||
<div className="typing"><span /><span /><span /></div>
|
||||
)}
|
||||
{msg.kind === 'html' && <div dangerouslySetInnerHTML={{ __html: msg.html }} />}
|
||||
{msg.kind === 'gen' && <GenCardView gen={msg.gen} confirm={msg.confirm} />}
|
||||
{msg.kind === 'task' && <TaskCardView task={msg.task} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useStore } from '../store/context'
|
||||
import { DEST_OPTIONS, DAY_OPTIONS, TYPE_OPTIONS, THEME_OPTIONS } from './cards/TitleCard'
|
||||
import { ocLevelInfo } from '../lib/oc'
|
||||
|
||||
/** 一键发品弹窗:配置 → 生成进度 → 完成(三视图状态机) */
|
||||
export default function OneClickModal() {
|
||||
const { ocModal, closeOC, setOCField, startOC, showToast, ocStore } = useStore()
|
||||
if (!ocModal.open) return null
|
||||
|
||||
const lv = ocLevelInfo(ocStore)
|
||||
const lvPct = ocStore.count >= 10 ? 100 : (ocStore.count / 10) * 100
|
||||
const stepMeta = [
|
||||
['商品标题', '已选高转化方案'],
|
||||
['详细介绍', '四段式卖点'],
|
||||
['商品详情', '图文结构就绪'],
|
||||
['商品图片', '配图清单 6 张'],
|
||||
['行程编辑', '逐日框架就绪'],
|
||||
['价格库存', '团期+定价就绪']
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="modal-mask show" onClick={e => { if (e.target === e.currentTarget) closeOC() }}>
|
||||
<div className="modal">
|
||||
{/* 视图 1:配置 */}
|
||||
{ocModal.view === 'config' && (
|
||||
<div>
|
||||
<h3>⚡ 一键发品</h3>
|
||||
<div className="sub">选好基础信息,AI 自动完成 6 大模块填写,30 秒搞定发品</div>
|
||||
{ocModal.learnVisible && (
|
||||
<div className="learn-hint" dangerouslySetInnerHTML={{ __html: ocModal.learnHtml }} />
|
||||
)}
|
||||
<div className="stat-row">
|
||||
<div className="stat-box"><div className="v">{ocStore.count}</div><div className="k">累计发品(次)</div></div>
|
||||
<div className="stat-box"><div className="v">{ocStore.prefs.length}</div><div className="k">已沉淀偏好(条)</div></div>
|
||||
<div className="stat-box"><div className="v">1<small> 分钟</small></div><div className="k">发品耗时(传统约 30 分钟)</div></div>
|
||||
</div>
|
||||
<div className="level-wrap">
|
||||
<div className="level-text"><span>{lv.name}</span><span>{lv.next}</span></div>
|
||||
<div className="level-bar"><i style={{ width: lvPct + '%' }} /></div>
|
||||
</div>
|
||||
<div className="conf-row">
|
||||
<div className="form-item">
|
||||
<label>目的地</label>
|
||||
<select value={ocModal.dest} onChange={e => setOCField('dest', e.target.value)}>
|
||||
{DEST_OPTIONS.map(o => <option key={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<label>天数</label>
|
||||
<select value={ocModal.days} onChange={e => setOCField('days', e.target.value)}>
|
||||
{DAY_OPTIONS.map(o => <option key={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<label>类型</label>
|
||||
<select value={ocModal.type} onChange={e => setOCField('type', e.target.value)}>
|
||||
{TYPE_OPTIONS.map(o => <option key={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<label>适合人群</label>
|
||||
<div className="chips">
|
||||
{THEME_OPTIONS.map(t => (
|
||||
<span
|
||||
key={t}
|
||||
className={'chip' + (ocModal.theme === t ? ' active' : '')}
|
||||
onClick={() => setOCField('theme', t)}
|
||||
>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
<button className="btn-ghost-w" onClick={closeOC}>取消</button>
|
||||
<button className="btn-magic" onClick={startOC}>⚡ 开始生成</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视图 2:生成进度 */}
|
||||
{ocModal.view === 'progress' && (
|
||||
<div>
|
||||
<h3>🤖 AI 正在发品…</h3>
|
||||
<div className="sub">按您的偏好逐个模块生成,完成后可随时接手微调</div>
|
||||
<div className="step-list">
|
||||
{stepMeta.map(([label, note], i) => {
|
||||
const st = ocModal.steps[i]?.status || 'todo'
|
||||
return (
|
||||
<div key={i} className={'step-item ' + st}>
|
||||
<span className="s-icon">{st === 'done' ? '✓' : st === 'doing' ? '↻' : '·'}</span>
|
||||
<span>{label}</span>
|
||||
<span className="s-note">{st === 'doing' ? '生成中…' : st === 'done' ? note : '等待中'}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视图 3:完成 */}
|
||||
{ocModal.view === 'done' && (
|
||||
<div>
|
||||
<div className="done-view">
|
||||
<div className="big">🎉</div>
|
||||
<h4>发品完成!</h4>
|
||||
<div className="dsub">6 大模块已全部生成,请核对后提交审核</div>
|
||||
</div>
|
||||
<div className="learn-hint" dangerouslySetInnerHTML={{ __html: ocModal.doneHtml }} />
|
||||
<div className="modal-foot">
|
||||
<button className="btn-ghost-w" onClick={closeOC}>返回核对</button>
|
||||
<button className="btn-magic" onClick={() => { closeOC(); showToast('已提交审核(演示)') }}>提交审核</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useStore } from '../store/context'
|
||||
|
||||
/** Agent 任务清单卡片:8 步/多步执行状态实时更新 */
|
||||
export default function TaskCardView({ task }) {
|
||||
const { setAutoConfirm, autoConfirmMode } = useStore()
|
||||
return (
|
||||
<div className="agent-card">
|
||||
<div className="ag-head">{task.title}</div>
|
||||
<div className="ag-ctx">{task.ctxLine}</div>
|
||||
<div className="ag-mode-row">
|
||||
<span className="ag-mode-label">确认模式</span>
|
||||
<button
|
||||
className={'ag-mt-btn' + (autoConfirmMode ? ' active' : '')}
|
||||
onClick={() => setAutoConfirm(true)}
|
||||
>⚡ 自动确认</button>
|
||||
<button
|
||||
className={'ag-mt-btn' + (!autoConfirmMode ? ' active' : '')}
|
||||
onClick={() => setAutoConfirm(false)}
|
||||
>✋ 手动确认</button>
|
||||
</div>
|
||||
<div className="ag-steps">
|
||||
{task.steps.map((s, i) => (
|
||||
<div key={i} className={'ag-step ' + s.status}>
|
||||
<span className="ag-ico">{s.status === 'done' ? '✓' : s.status === 'doing' ? '↻' : '·'}</span>
|
||||
<span className="ag-lbl">{s.label}</span>
|
||||
<span className="ag-note">{s.note}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="ag-foot">💡 每个工具生成后展示卡片,自动模式 5s 后自动确认填入,手动模式需您点击确认。可随时切换模式。</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useStore } from '../store/context'
|
||||
|
||||
/** 全局轻提示(1.8s 自动消失,与 v1 行为一致) */
|
||||
export default function Toast() {
|
||||
const { toastState } = useStore()
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!toastState) return
|
||||
setVisible(true)
|
||||
const t = setTimeout(() => setVisible(false), 1800)
|
||||
return () => clearTimeout(t)
|
||||
}, [toastState])
|
||||
|
||||
return (
|
||||
<div className={'toast' + (visible ? ' show' : '')}>
|
||||
{toastState ? toastState.text : ''}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useStore } from '../store/context'
|
||||
|
||||
/** 顶部导航:logo / 面包屑 / 草稿状态 */
|
||||
export default function TopBar() {
|
||||
const { showToast } = useStore()
|
||||
return (
|
||||
<div className="topbar">
|
||||
<div className="logo"><div className="icon">✈</div> 旅智工作台</div>
|
||||
<div className="breadcrumb">商品管理 / <b>发布新商品</b></div>
|
||||
<div className="right">
|
||||
<span>草稿自动保存于 1 分钟前</span>
|
||||
<div className="avatar-sm">李</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** 通用卡片骨架:标题 + AI 内嵌按钮 + 模块序号(refCb 用于 AI 填入后的高亮定位) */
|
||||
export default function Card({ id, title, aiLabel, aiTitle, onAI, step, refCb, children }) {
|
||||
return (
|
||||
<div className="card" id={id} ref={refCb}>
|
||||
<h2>
|
||||
<span className="dot" />
|
||||
{title}
|
||||
{onAI && (
|
||||
<button className="ai-inline-btn" title={aiTitle} onClick={onAI}>{aiLabel}</button>
|
||||
)}
|
||||
<span className="badge-step">{step}</span>
|
||||
</h2>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useStore } from '../../store/context'
|
||||
import Card from './Card'
|
||||
|
||||
/** 模块 3/6 —— 商品详情(contenteditable 富文本,AI 可直接写入) */
|
||||
export default function DetailCard({ setRef }) {
|
||||
const { formAI, detailRef } = useStore()
|
||||
return (
|
||||
<Card
|
||||
id="card-detail" refCb={setRef('card-detail')} title="商品详情" step="模块 3/6"
|
||||
aiLabel="✨ AI 生成详情" aiTitle="AI 生成结构化图文详情"
|
||||
onAI={() => formAI('detail')}
|
||||
>
|
||||
<div className="form-item">
|
||||
<label><span className="req">*</span>详情页图文内容</label>
|
||||
<div className="rich-editor">
|
||||
<div className="rich-toolbar">
|
||||
<button title="加粗">B</button>
|
||||
<button title="斜体">I</button>
|
||||
<button title="标题">H</button>
|
||||
<div className="sep" />
|
||||
<button title="插入图片">🖼️</button>
|
||||
<button title="插入视频">🎬</button>
|
||||
<div className="sep" />
|
||||
<button title="行程模块">🗺️</button>
|
||||
<button title="费用说明">¥</button>
|
||||
</div>
|
||||
<div
|
||||
className="rich-content"
|
||||
ref={el => { detailRef.current = el }}
|
||||
contentEditable
|
||||
data-placeholder="支持图文混排,AI 可生成结构化详情内容…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useStore } from '../../store/context'
|
||||
import Card from './Card'
|
||||
import { imgPalette } from '../../data/genData'
|
||||
|
||||
/** 模块 4/6 —— 商品图片(AI 可按清单批量添加配图占位) */
|
||||
export default function ImageCard({ setRef }) {
|
||||
const { images, uploadImg, removeImage, formAI } = useStore()
|
||||
return (
|
||||
<Card
|
||||
id="card-img" refCb={setRef('card-img')} title="商品图片" step="模块 4/6"
|
||||
aiLabel="✨ AI 配图建议" aiTitle="AI 推荐拍摄脚本与配图清单"
|
||||
onAI={() => formAI('image')}
|
||||
>
|
||||
<div className="form-item">
|
||||
<label><span className="req">*</span>主图与详情图(首张为主图,建议 800×600 以上)</label>
|
||||
<div className="img-grid">
|
||||
{images.map((im, i) => {
|
||||
const p = imgPalette[i % imgPalette.length]
|
||||
return (
|
||||
<div key={i} className="img-tile" style={{ background: p.g }}>
|
||||
<span className="tag">{i === 0 ? '主图' : '详情图 ' + i}</span>
|
||||
{im.ai && <span className="ai-tag">AI 建议</span>}
|
||||
<span className="em">{p.em}</span><span>{im.label}</span>
|
||||
<button className="del" onClick={() => removeImage(i)}>✕</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="img-upload" onClick={uploadImg}><span className="plus">+</span>上传图片</div>
|
||||
</div>
|
||||
<div className="hint" style={{ marginTop: 10 }}>AI 可根据行程内容推荐拍摄脚本与配图清单</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useStore } from '../../store/context'
|
||||
import Card from './Card'
|
||||
|
||||
/** 模块 2/6 —— 详细介绍(列表页卖点摘要) */
|
||||
export default function IntroCard({ setRef }) {
|
||||
const { form, updateForm, formAI, introHighlight } = useStore()
|
||||
return (
|
||||
<Card
|
||||
id="card-intro" refCb={setRef('card-intro')} title="详细介绍" step="模块 2/6"
|
||||
aiLabel="✨ AI 生成卖点" aiTitle="AI 生成四段式卖点摘要"
|
||||
onAI={() => formAI('intro')}
|
||||
>
|
||||
<div className="form-item">
|
||||
<label><span className="req">*</span>详细介绍(列表页展示的卖点摘要)</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
placeholder="突出行程亮点、住宿标准、服务承诺等核心卖点…"
|
||||
value={form.intro}
|
||||
className={introHighlight ? 'field-highlight' : ''}
|
||||
onChange={e => updateForm('intro', e.target.value)}
|
||||
/>
|
||||
<div className="hint">建议 150-300 字,聚焦用户最关心的 3-4 个卖点</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useStore } from '../../store/context'
|
||||
import Card from './Card'
|
||||
|
||||
/** 模块 5/6 —— 行程编辑(逐日行程卡片) */
|
||||
export default function ItineraryCard({ setRef }) {
|
||||
const { itineraryDays, addDay, removeDay, updateDay, formAI } = useStore()
|
||||
return (
|
||||
<Card
|
||||
id="card-itinerary" refCb={setRef('card-itinerary')} title="行程编辑" step="模块 5/6"
|
||||
aiLabel="✨ AI 规划行程" aiTitle="AI 按天数生成逐日行程"
|
||||
onAI={() => formAI('itinerary')}
|
||||
>
|
||||
{itineraryDays.length === 0 ? (
|
||||
<div className="empty-tip">暂无行程,点击下方按钮添加,或让 AI 一键生成 ↗</div>
|
||||
) : (
|
||||
itineraryDays.map((d, i) => (
|
||||
<div key={i} className="day-card">
|
||||
<div className="day-head">
|
||||
<span className="dnum">D{i + 1}</span>
|
||||
<input
|
||||
value={d.title}
|
||||
placeholder="当日主题,如:抵达昆明·入住酒店"
|
||||
onChange={e => updateDay(i, 'title', e.target.value)}
|
||||
/>
|
||||
<button className="del-day" title="删除当天" onClick={() => removeDay(i)}>✕</button>
|
||||
</div>
|
||||
<div className="day-body">
|
||||
<textarea
|
||||
placeholder="当日安排描述…"
|
||||
value={d.desc}
|
||||
onChange={e => updateDay(i, 'desc', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<button className="add-day" onClick={addDay}>+ 添加一天行程</button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useStore } from '../../store/context'
|
||||
import Card from './Card'
|
||||
|
||||
/** 模块 6/6 —— 价格库存(团期排期表) */
|
||||
export default function PriceCard({ setRef }) {
|
||||
const { priceRows, addPriceRow, removePriceRow, updatePriceRow, togglePriceOn, formAI } = useStore()
|
||||
return (
|
||||
<Card
|
||||
id="card-price" refCb={setRef('card-price')} title="价格库存" step="模块 6/6"
|
||||
aiLabel="✨ AI 排期定价" aiTitle="AI 生成团期排期与定价方案"
|
||||
onAI={() => formAI('price')}
|
||||
>
|
||||
<table className="price-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 150 }}>出发日期</th>
|
||||
<th>成人价(元)</th>
|
||||
<th>儿童价(元)</th>
|
||||
<th style={{ width: 90 }}>库存</th>
|
||||
<th style={{ width: 80 }}>状态</th>
|
||||
<th style={{ width: 40 }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{priceRows.length === 0 ? (
|
||||
<tr><td colSpan={6}><div className="empty-tip">暂无团期,点击下方按钮添加,或让 AI 生成排期方案 ↗</div></td></tr>
|
||||
) : (
|
||||
priceRows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td><input value={r.date} onChange={e => updatePriceRow(i, 'date', e.target.value)} /></td>
|
||||
<td><input type="number" value={r.adult} onChange={e => updatePriceRow(i, 'adult', e.target.value)} /></td>
|
||||
<td><input type="number" value={r.child} onChange={e => updatePriceRow(i, 'child', e.target.value)} /></td>
|
||||
<td>
|
||||
<input
|
||||
type="number" value={r.stock}
|
||||
className={r.stock < 6 ? 'stock-low' : ''}
|
||||
onChange={e => updatePriceRow(i, 'stock', +e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={'status-pill ' + (r.on ? 'status-on' : 'status-off')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => togglePriceOn(i)}
|
||||
>{r.on ? '在售' : '停售'}</span>
|
||||
</td>
|
||||
<td><button className="del-row" onClick={() => removePriceRow(i)}>✕</button></td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<button className="add-day" style={{ marginTop: 10 }} onClick={addPriceRow}>+ 添加团期</button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useStore } from '../../store/context'
|
||||
import Card from './Card'
|
||||
|
||||
export const DEST_OPTIONS = ['云南(昆明/大理/丽江)', '四川(成都/九寨沟)', '海南(三亚)', '新疆(乌鲁木齐/喀纳斯)', '西藏(拉萨/林芝)']
|
||||
export const DAY_OPTIONS = ['6天5晚', '3天2晚', '5天4晚', '7天6晚', '8天7晚']
|
||||
export const TYPE_OPTIONS = ['跟团游', '自由行', '定制游', '半自助游', '当地玩乐']
|
||||
export const THEME_OPTIONS = ['亲子家庭', '蜜月情侣', '银发无忧', '摄影爱好', '小众深度']
|
||||
|
||||
/** 模块 1/6 —— 商品标题(含目的地/天数/类型/人群 上下文) */
|
||||
export default function TitleCard({ setRef }) {
|
||||
const { form, updateForm, formAI, titleHighlight } = useStore()
|
||||
return (
|
||||
<Card
|
||||
id="card-title" refCb={setRef('card-title')} title="商品标题" step="模块 1/6"
|
||||
aiLabel="✨ AI 生成标题" aiTitle="AI 生成 3 个高转化标题"
|
||||
onAI={() => formAI('title')}
|
||||
>
|
||||
<div className="form-row">
|
||||
<div className="form-item" style={{ flex: 2 }}>
|
||||
<label><span className="req">*</span>商品标题</label>
|
||||
<input
|
||||
type="text" maxLength={40}
|
||||
placeholder="例:云南昆明+大理+丽江6日5晚纯玩跟团游"
|
||||
value={form.title}
|
||||
className={titleHighlight ? 'field-highlight' : ''}
|
||||
onChange={e => updateForm('title', e.target.value)}
|
||||
/>
|
||||
<div className="counter"><span>{form.title.length}</span>/40 字</div>
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<label>目的地</label>
|
||||
<select value={form.dest} onChange={e => updateForm('dest', e.target.value)}>
|
||||
{DEST_OPTIONS.map(o => <option key={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-item">
|
||||
<label>行程天数</label>
|
||||
<select value={form.days} onChange={e => updateForm('days', e.target.value)}>
|
||||
{DAY_OPTIONS.map(o => <option key={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<label>商品类型</label>
|
||||
<select value={form.type} onChange={e => updateForm('type', e.target.value)}>
|
||||
{TYPE_OPTIONS.map(o => <option key={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-item" style={{ flex: 2 }}>
|
||||
<label>适合人群</label>
|
||||
<div className="chips">
|
||||
{THEME_OPTIONS.map(t => (
|
||||
<span
|
||||
key={t}
|
||||
className={'chip' + (form.theme === t ? ' active' : '')}
|
||||
onClick={() => updateForm('theme', t)}
|
||||
>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/* ============================================================
|
||||
* 工具(Tool)定义:Agent 可调用的 8 个生成工具
|
||||
* buildGen(type, ctx) 返回结构化结果:
|
||||
* pre —— 助手开场白(应用内静态 HTML,非用户输入)
|
||||
* card —— 生成卡片结构(head / lines / foot / tips),由 GenCardView 渲染
|
||||
* data —— 填入表单的结构化数据
|
||||
* ============================================================ */
|
||||
|
||||
/** 自主发品 Agent 的 8 步任务清单 */
|
||||
export const agentSteps = [
|
||||
{ label: '商品标题', type: 'title', note: '3 方案·已选高转化款填入' },
|
||||
{ label: '详细介绍', type: 'intro', note: '四段式卖点已填入' },
|
||||
{ label: '商品详情', type: 'detail', note: '图文结构已写入编辑器' },
|
||||
{ label: '商品图片', type: 'image', note: '6 张配图清单已添加' },
|
||||
{ label: '行程编辑', type: 'itinerary', note: '逐日行程已填入' },
|
||||
{ label: '价格库存', type: 'price', note: '5 个团期 + 定价已填入' },
|
||||
{ label: 'SEO 关键词', type: 'seo', note: '6 个高流量词已提取' },
|
||||
{ label: '合规检查', type: 'check', note: '扫描完成·0 极限词风险' }
|
||||
]
|
||||
|
||||
/** 一键发品的 6 步流程 */
|
||||
export const ocStepsDef = [
|
||||
{ label: '商品标题', type: 'title', note: '已选高转化方案' },
|
||||
{ label: '详细介绍', type: 'intro', note: '四段式卖点' },
|
||||
{ label: '商品详情', type: 'detail', note: '图文结构就绪' },
|
||||
{ label: '商品图片', type: 'image', note: '配图清单 6 张' },
|
||||
{ label: '行程编辑', type: 'itinerary', note: '逐日框架就绪' },
|
||||
{ label: '价格库存', type: 'price', note: '团期+定价就绪' }
|
||||
]
|
||||
|
||||
/** 各工具的动作名称(对话中展示用) */
|
||||
export const actionLabel = {
|
||||
title: '生成商品标题', intro: '生成详细介绍', detail: '生成商品详情', image: '配图建议',
|
||||
itinerary: '规划行程', price: '价格库存方案', seo: 'SEO 关键词', check: '合规检查'
|
||||
}
|
||||
|
||||
/** 图片占位调色板 */
|
||||
export const imgPalette = [
|
||||
{ g: 'linear-gradient(135deg,#ffb300,#ff7a1a)', em: '🚤' },
|
||||
{ g: 'linear-gradient(135deg,#f59e0b,#ef4444)', em: '🏮' },
|
||||
{ g: 'linear-gradient(135deg,#8b5cf6,#ec4899)', em: '🏔️' },
|
||||
{ g: 'linear-gradient(135deg,#22c55e,#ffb300)', em: '🏨' },
|
||||
{ g: 'linear-gradient(135deg,#f97316,#facc15)', em: '🍲' },
|
||||
{ g: 'linear-gradient(135deg,#6366f1,#ffb300)', em: '🌅' }
|
||||
]
|
||||
|
||||
/** 表单模块 → 卡片锚点(滚动定位用) */
|
||||
export const cardMap = {
|
||||
title: 'card-title', intro: 'card-intro', detail: 'card-detail',
|
||||
image: 'card-img', itinerary: 'card-itinerary', price: 'card-price'
|
||||
}
|
||||
|
||||
/** 生成 8 类工具的结构化结果(ctx 为当前表单上下文) */
|
||||
export function buildGen(type, ctx) {
|
||||
const { dest, days, type: pType, theme } = ctx
|
||||
const isYn = dest === '云南'
|
||||
|
||||
switch (type) {
|
||||
case 'title': {
|
||||
const t = [
|
||||
`【纯玩无购物】${dest}${days}精品${pType}·5A景区全含·${theme}首选`,
|
||||
`${dest}${days}深度${pType}|一价全含0自费·住${isYn ? '海景' : '景区'}酒店`,
|
||||
`【${theme}必选】${dest}${days}${pType}·随走随停·金牌向导·赠${isYn ? '洱海游船' : '旅拍'}体验`
|
||||
]
|
||||
return {
|
||||
pre: `结合 <span class="hl">${dest}·${days}·${pType}·${theme}</span> 生成 3 个高点击标题(可多次点击填入切换方案):`,
|
||||
card: {
|
||||
head: '✨ 标题方案(三选一)',
|
||||
lines: t.map((x, i) => `<b>${i + 1}.</b> ${x}`),
|
||||
foot: { apply: 'title', label: '⚡ 一键填入「商品标题」', regen: true },
|
||||
tips: [
|
||||
{ cls: 'ok-pill', text: '含目的地+天数+类型,利于搜索' },
|
||||
{ cls: '', text: '「无购物」为平台高转化词' }
|
||||
]
|
||||
},
|
||||
data: t
|
||||
}
|
||||
}
|
||||
case 'intro': {
|
||||
const d = `【${dest}${days}${pType}·${theme}优选】全程 0 购物 0 自费,景区门票一价全含,金牌持证导游全程讲解。\n精选 ${days === '3天2晚' ? '2' : '4'} 晚高分酒店,${isYn ? '大理海景房 + 丽江古城客栈' : '近景区步行可达'},住的舒心才是真度假。\n特色体验:${isYn ? '白族扎染 + 洱海游船 + 野生菌火锅' : '深度环线,不赶路'},孩子玩得开心、大人拍得尽兴。\n服务承诺:行程透明无隐形消费,24h 专属管家,未成团提前 3 天全额退款。`
|
||||
return {
|
||||
pre: `围绕「${theme}」人群痛点,按「亮点—住宿—体验—承诺」四段式生成详细介绍:`,
|
||||
card: {
|
||||
head: '✨ 详细介绍(列表页卖点摘要)',
|
||||
lines: d.split('\n'),
|
||||
foot: { apply: 'intro', label: '⚡ 一键填入「详细介绍」', regen: true },
|
||||
tips: [
|
||||
{ cls: 'ok-pill', text: '约 200 字,符合最佳展示长度' },
|
||||
{ cls: '', text: '含服务承诺,降低决策门槛' }
|
||||
]
|
||||
},
|
||||
data: d
|
||||
}
|
||||
}
|
||||
case 'detail': {
|
||||
return {
|
||||
pre: '已生成结构化商品详情(亮点/住宿/餐饮/保障 + 配图占位),可直接在编辑器中继续润色:',
|
||||
card: {
|
||||
head: '✨ 商品详情(图文结构)',
|
||||
lines: [
|
||||
`<b>行程亮点</b> · 0购物0自费 / 经典IP全含 / ${theme}节奏`,
|
||||
`<b>住宿安排</b> · ${isYn ? '海景房+古城客栈' : '高分酒店'}`,
|
||||
`<b>舌尖体验</b> · ${isYn ? '野生菌火锅+风味餐' : '特色餐 3 次'}`,
|
||||
`<b>服务保障</b> · 金牌导游 / 24h管家 / 全额退款承诺`,
|
||||
`<b>配图占位</b> · 行程地图 / 酒店实拍 / 餐饮实拍`
|
||||
],
|
||||
foot: { apply: 'detail', label: '⚡ 写入「商品详情」编辑器', regen: true },
|
||||
tips: []
|
||||
},
|
||||
data: `<h3>🌟 行程亮点</h3>
|
||||
<ul>
|
||||
<li>全程 <b>0 购物 0 自费</b>,行程透明,把时间还给风景</li>
|
||||
<li>${dest}经典 IP 全含:${isYn ? '石林、洱海、玉龙雪山、丽江古城' : '核心景区深度游览'}</li>
|
||||
<li>${theme}专属节奏:每日行车 ≤ 3.5 小时,不赶路不早起(可睡到自然醒)</li>
|
||||
</ul>
|
||||
<h3>🏨 住宿安排</h3>
|
||||
<ul>
|
||||
<li>${isYn ? '大理 1 晚海景房 + 丽江 2 晚古城客栈' : '全程高分舒适型酒店'}</li>
|
||||
<li>提前公布酒店名单,拒绝到店换酒店</li>
|
||||
</ul>
|
||||
<h3>🍜 舌尖体验</h3>
|
||||
<ul>
|
||||
<li>${isYn ? '野生菌火锅 + 白族风味餐 + 过桥米线' : '当地特色风味餐 3 次'}</li>
|
||||
</ul>
|
||||
<h3>🛡️ 服务保障</h3>
|
||||
<ul>
|
||||
<li>持证金牌导游 + 24h 专属管家</li>
|
||||
<li>未成团提前 3 天全额退款,出行无忧</li>
|
||||
</ul>
|
||||
<div class="img-slot">🖼️ 此处建议插入:行程地图 / 实拍酒店 / 餐饮实拍(见「配图建议」)</div>`
|
||||
}
|
||||
}
|
||||
case 'image': {
|
||||
const list = [
|
||||
{ label: `${dest}全景主图(横版大图)`, use: '主图' },
|
||||
{ label: '酒店实拍·房间内饰', use: '详情' },
|
||||
{ label: '核心景区打卡机位', use: '详情' },
|
||||
{ label: '特色餐食实拍', use: '详情' },
|
||||
{ label: `${theme}互动瞬间抓拍`, use: '详情' },
|
||||
{ label: '行程地图示意', use: '详情' }
|
||||
]
|
||||
return {
|
||||
pre: '根据行程内容生成 <b>6 张配图拍摄脚本</b>(含用途与构图建议):',
|
||||
card: {
|
||||
head: '✨ 配图清单(转化率优选顺序)',
|
||||
lines: list.map((im, i) => `<b>${i + 1}.</b> ${im.label} <span style="color:#86909c;font-size:11px">[${im.use}]</span>`),
|
||||
foot: { apply: 'image', label: '⚡ 按清单添加到「商品图片」', regen: true },
|
||||
tips: [
|
||||
{ cls: 'ok-pill', text: '主图含地标,点击率 +18%' },
|
||||
{ cls: '', text: '实拍图 > 精修图,差评更少' }
|
||||
]
|
||||
},
|
||||
data: list.map(x => ({ label: x.use === '主图' ? x.label : x.label.replace(/(.*)/, '') }))
|
||||
}
|
||||
}
|
||||
case 'itinerary': {
|
||||
const dayList = [
|
||||
{ title: isYn ? '抵达昆明·专车接站' : `抵达${dest}·专车接站`, desc: '落地即接,24h 接站服务,入住酒店后自由活动,推荐逛逛翠湖公园。' },
|
||||
{ title: isYn ? '石林风景区 → 楚雄' : '核心景区深度游', desc: '上午游览 5A 石林,午后赴楚雄,晚观彝族歌舞表演。' },
|
||||
{ title: isYn ? '大理古城 → 洱海游船 → 双廊' : '慢节奏主题日', desc: '上午漫步大理古城,午后洱海游船,双廊海边下午茶,看日落。' },
|
||||
{ title: isYn ? '丽江古城 → 玉龙雪山 → 蓝月谷' : '自由活动日', desc: '索道登玉龙雪山4506m,蓝月谷拍照,晚逛丽江四方街。' },
|
||||
{ title: isYn ? '束河古镇 → 返回昆明' : '自由活动·休整', desc: '睡到自然醒,逛束河古镇,午后大巴返回昆明。' },
|
||||
{ title: '返程日·专车送站', desc: '根据航班时间安排送站,行程圆满结束。' }
|
||||
].slice(0, parseInt(days) || 6)
|
||||
return {
|
||||
pre: `按 <span class="hl">${days}·${pType}·${theme}</span> 节奏规划每日行程,张弛有度:`,
|
||||
card: {
|
||||
head: '✨ 行程框架(每日含主题与安排)',
|
||||
lines: dayList.map((d, i) => `<b>D${i + 1}</b> ${d.title}`),
|
||||
foot: { apply: 'itinerary', label: '⚡ 填入「行程编辑」', regen: true },
|
||||
tips: [
|
||||
{ cls: 'ok-pill', text: `每日行车 ≤ 3.5h,适合${theme}` },
|
||||
{ cls: '', text: 'D1/D6 只做接驳,差评率更低' }
|
||||
]
|
||||
},
|
||||
data: dayList
|
||||
}
|
||||
}
|
||||
case 'price': {
|
||||
const rows = [
|
||||
{ date: '2026-09-12', adult: 3299, child: 2299, stock: 18, on: true },
|
||||
{ date: '2026-09-26', adult: 3499, child: 2399, stock: 12, on: true },
|
||||
{ date: '2026-10-01', adult: 4199, child: 2899, stock: 25, on: true },
|
||||
{ date: '2026-10-03', adult: 4199, child: 2899, stock: 5, on: true },
|
||||
{ date: '2026-10-17', adult: 3399, child: 2349, stock: 20, on: true }
|
||||
]
|
||||
return {
|
||||
pre: `基于近 30 天 <span class="hl">${dest}·${days}·${pType}</span> 成交数据,生成 5 个团期排期与定价(含国庆档):`,
|
||||
card: {
|
||||
head: '✨ 价格库存方案',
|
||||
lines: [
|
||||
`📊 市场区间:<b>¥2,899-¥4,199</b>(${theme}均价 ¥3,480)`,
|
||||
`🎯 平季定价:<b>¥3,299</b>(低于均价 5%,点击率预估 +23%)`,
|
||||
`📈 国庆档:<b>¥4,199</b>·库存充足(预计售罄率 85%)`,
|
||||
`💡 策略:首月立减 ¥300 冲新品榜,稳定后回调`
|
||||
],
|
||||
foot: { apply: 'price', label: '⚡ 填入「价格库存」表', regen: true },
|
||||
tips: [
|
||||
{ cls: 'ok-pill', text: '低于均价 5%,排序更优' },
|
||||
{ cls: 'warn-pill', text: '10-03 团期库存偏低,建议 +10' }
|
||||
]
|
||||
},
|
||||
data: rows
|
||||
}
|
||||
}
|
||||
case 'seo': {
|
||||
const kw = `${dest}旅游,${dest}${days},${pType},${theme}旅游,${dest}纯玩无购物,${dest}自由行攻略`
|
||||
const heat = [8200, 6500, 5400, 3900, 2800, 1900]
|
||||
return {
|
||||
pre: '提取 <b>6 个高流量关键词</b>,按搜索热度排序:',
|
||||
card: {
|
||||
head: '✨ SEO 关键词(月均搜索指数)',
|
||||
lines: kw.split(',').map((k, i) => `<b>${i + 1}.</b> ${k} <span style="color:#86909c;font-size:11px">热度 ${heat[i]}</span>`),
|
||||
foot: { apply: null, label: '📋 复制关键词', regen: true },
|
||||
tips: []
|
||||
},
|
||||
data: kw
|
||||
}
|
||||
}
|
||||
case 'check': {
|
||||
return {
|
||||
pre: '已对 6 大模块进行 <b>广告法 + 平台规则</b> 双重合规扫描:',
|
||||
card: {
|
||||
head: '🛡️ 合规检查报告',
|
||||
lines: [
|
||||
'✅ 标题:无「最」「第一」等极限词',
|
||||
'✅ 详细介绍:无「国家级」「顶级」违禁表述',
|
||||
'⚠️ 建议:「金牌导游」表述需补充资质证明材料',
|
||||
'⚠️ 建议:「全额退款」承诺需与退改政策页一致',
|
||||
'ℹ️ 提示:主图 800×600 以上,避免文字遮挡地标'
|
||||
],
|
||||
foot: null,
|
||||
tips: [
|
||||
{ cls: 'ok-pill', text: '极限词 0 风险' },
|
||||
{ cls: 'warn-pill', text: '2 项待补充' },
|
||||
{ cls: '', text: '审核通过率预估 96%' }
|
||||
]
|
||||
},
|
||||
data: null
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error('unknown tool type: ' + type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* ============================================================
|
||||
* Agent 意图解析层
|
||||
* 把商家的自然语言(如「帮我发个海南亲子6天自由行」)
|
||||
* 解析为结构化配置 / 工具调用清单 —— 演示 Agent 的意图理解与编排。
|
||||
* ============================================================ */
|
||||
|
||||
/** 「自主发品」意图识别 */
|
||||
export const agentIntentRe = /(发品|发布|上架|帮我发|全部生成|全都|六大模块|6大模块|30秒|搞定|一键发)/
|
||||
|
||||
/** 目的地/人群 映射表 */
|
||||
const DEST_MAP = [
|
||||
['云南', '云南(昆明/大理/丽江)'],
|
||||
['四川', '四川(成都/九寨沟)'],
|
||||
['海南', '海南(三亚)'],
|
||||
['三亚', '海南(三亚)'],
|
||||
['新疆', '新疆(乌鲁木齐/喀纳斯)'],
|
||||
['西藏', '西藏(拉萨/林芝)']
|
||||
]
|
||||
const THEME_MAP = [
|
||||
['亲子', '亲子家庭'], ['蜜月', '蜜月情侣'], ['银发', '银发无忧'],
|
||||
['摄影', '摄影爱好'], ['小众', '小众深度'], ['深度', '小众深度']
|
||||
]
|
||||
const TYPES = ['跟团游', '自由行', '定制游', '半自助游', '当地玩乐']
|
||||
|
||||
/** 从自然语言里提取配置(目的地/天数/类型/人群) */
|
||||
export function parseBrief(t) {
|
||||
const o = {}
|
||||
for (const [k, v] of DEST_MAP) if (t.includes(k)) { o.dest = v; break }
|
||||
for (const ty of TYPES) if (t.includes(ty)) { o.type = ty; break }
|
||||
for (const [k, v] of THEME_MAP) if (t.includes(k)) { o.theme = v; break }
|
||||
const dm = t.match(/(\d+)\s*[天日]/)
|
||||
if (dm) {
|
||||
const n = +dm[1]
|
||||
o.days = ['3天2晚', '5天4晚', '6天5晚', '7天6晚', '8天7晚'].find(d => d.startsWith(n + '天')) || (n + '天' + (n - 1) + '晚')
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
/** 工具关键词表:自然语言 → 工具调用序列(保序去重) */
|
||||
export const toolKeywords = [
|
||||
{ type: 'title', kws: ['标题', '名字'] },
|
||||
{ type: 'intro', kws: ['介绍', '卖点'] },
|
||||
{ type: 'detail', kws: ['详情'] },
|
||||
{ type: 'image', kws: ['图', '配图', '拍摄'] },
|
||||
{ type: 'itinerary', kws: ['行程', '路线', '规划'] },
|
||||
{ type: 'price', kws: ['价', '定价', '库存', '团期', '多少钱'] },
|
||||
{ type: 'seo', kws: ['seo', '关键词', '搜索'] },
|
||||
{ type: 'check', kws: ['合规', '违禁', '广告法'] }
|
||||
]
|
||||
|
||||
/** 从自然语言中提取用户提到的工具(去重、保序) */
|
||||
export function parseMultiTools(text) {
|
||||
const lower = text.toLowerCase()
|
||||
const found = [], seen = new Set()
|
||||
for (const { type, kws } of toolKeywords) {
|
||||
if (seen.has(type)) continue
|
||||
if (kws.some(k => lower.includes(k))) { found.push(type); seen.add(type) }
|
||||
}
|
||||
return found
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* ============================================================
|
||||
* 「越用越好用」偏好学习
|
||||
* ocStore 持久化到 localStorage:发品次数、沉淀的偏好、最近一次配置。
|
||||
* ============================================================ */
|
||||
|
||||
const OC_KEY = 'fliggy_oc'
|
||||
|
||||
export function loadOC() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(OC_KEY) || 'null') || { count: 0, prefs: [], last: null }
|
||||
} catch {
|
||||
return { count: 0, prefs: [], last: null }
|
||||
}
|
||||
}
|
||||
|
||||
export function saveOC(store) {
|
||||
try { localStorage.setItem(OC_KEY, JSON.stringify(store)) } catch { /* 演示环境忽略 */ }
|
||||
}
|
||||
|
||||
/** 成长等级:Lv.1 通用模板 → Lv.2 风格匹配 → Lv.3 爆款复刻 */
|
||||
export function ocLevelInfo(store) {
|
||||
if (store.count >= 10) return { name: 'Lv.3 爆款复刻', next: '已解锁:自动复用您的历史爆款结构与定价', pct: 100 }
|
||||
if (store.count >= 3) return { name: 'Lv.2 风格匹配', next: `再用 ${10 - store.count} 次解锁「Lv.3 爆款复刻」`, pct: (store.count / 10) * 100 }
|
||||
return { name: 'Lv.1 通用模板', next: `再用 ${3 - store.count} 次解锁「Lv.2 风格匹配」(生成将贴合您的风格)`, pct: (store.count / 10) * 100 }
|
||||
}
|
||||
|
||||
/** 每次发品沉淀的偏好条目 */
|
||||
export function buildPrefs(destShort, days, type, theme) {
|
||||
return [
|
||||
`${destShort}路线偏好:${days}${type}`,
|
||||
`「${theme}」人群文案风格`,
|
||||
'纯玩无购物话术偏好',
|
||||
'平季低于市场 5% 定价策略',
|
||||
'首月立减冲榜促销习惯'
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/global.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
/** 全局 Store 上下文:App 中用 useAppStore() 创建并注入 */
|
||||
export const StoreContext = createContext(null)
|
||||
|
||||
export function useStore() {
|
||||
const store = useContext(StoreContext)
|
||||
if (!store) throw new Error('useStore 必须在 <StoreContext.Provider> 内使用')
|
||||
return store
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
/* ============================================================
|
||||
* 应用状态中枢(useAppStore)
|
||||
* - 表单状态 / 会话消息 / 生成结果缓存 / 串行确认 / 一键发品弹窗
|
||||
* - 异步编排流(quickAction / agentRun / multiToolRun / startOC)
|
||||
* 全部通过 ref 读取最新状态,避免闭包过期问题
|
||||
* ============================================================ */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { buildGen, agentSteps, ocStepsDef, actionLabel, cardMap } from '../data/genData'
|
||||
import { parseBrief, parseMultiTools, agentIntentRe } from '../lib/agent'
|
||||
import { loadOC, saveOC, ocLevelInfo, buildPrefs } from '../lib/oc'
|
||||
|
||||
const delay = ms => new Promise(r => setTimeout(r, ms))
|
||||
const esc = s => s.replace(/</g, '<')
|
||||
let msgSeq = 0
|
||||
|
||||
const INITIAL_FORM = {
|
||||
title: '',
|
||||
dest: '云南(昆明/大理/丽江)',
|
||||
days: '6天5晚',
|
||||
type: '跟团游',
|
||||
theme: '亲子家庭',
|
||||
intro: ''
|
||||
}
|
||||
|
||||
const WELCOME_1 = `您好,我是 <b>旅智小助</b> 🤖<br>我对应左侧 6 大表单模块,可以帮您:<br>· 📝 商品标题 — 3 个方案一键切换填入<br>· 💎 详细介绍 — 四段式卖点文案<br>· 📄 商品详情 — 结构化图文(含配图占位)<br>· 🖼️ 配图建议 — 6 张图拍摄脚本<br>· 🗺️ 行程编辑 — 逐日行程一键填入<br>· 💰 价格库存 — 团期排期 + 定价策略<br><br>💬 <b>自主发品</b>:说「<b>帮我优化标题和详情</b>」或「<b>帮我发个云南亲子 6 天跟团游</b>」,我会<b>逐个工具生成 → 展示卡片 → 等您确认</b>再填入表单(⚡自动 5s 确认,可切✋手动)~`
|
||||
|
||||
const WELCOME_2 = `💡 <b>演示提示</b>:<br>· 说「<b>帮我优化标题和详情</b>」→ 串行调度指定模块<br>· 说「<b>帮我发个海南亲子 6 天自由行</b>」→ 自主调用全部 8 个工具<br>· 每步<b>生成卡片 → 等您确认</b>再填入(⚡自动 5s / ✋手动可随时切换)<br>· 也可点 <b>⚡ 一键发品</b> 用弹窗方式完成<br>都会沉淀偏好,<b>用得越多越懂你</b>。`
|
||||
|
||||
const CANNED = [
|
||||
{ k: ['标题', '名字'], type: 'title' },
|
||||
{ k: ['介绍', '卖点'], type: 'intro' },
|
||||
{ k: ['详情'], type: 'detail' },
|
||||
{ k: ['图', '配图', '拍摄'], type: 'image' },
|
||||
{ k: ['行程', '规划', '路线'], type: 'itinerary' },
|
||||
{ k: ['价', '定价', '库存', '团期', '多少钱'], type: 'price' },
|
||||
{ k: ['seo', '关键词', '搜索'], type: 'seo' },
|
||||
{ k: ['合规', '违禁', '广告法'], type: 'check' }
|
||||
]
|
||||
|
||||
export function useAppStore() {
|
||||
/* ---------------- 基础状态 ---------------- */
|
||||
const [form, setForm] = useState(INITIAL_FORM)
|
||||
const [images, setImages] = useState([])
|
||||
const [itineraryDays, setItineraryDays] = useState([])
|
||||
const [priceRows, setPriceRows] = useState([])
|
||||
const [messages, setMessages] = useState([])
|
||||
const [panelCollapsed, setPanelCollapsed] = useState(false)
|
||||
const [toastState, setToastState] = useState(null) // { text, ts }
|
||||
const [countdown, setCountdown] = useState(0) // 串行确认倒计时(秒)
|
||||
const [autoConfirmMode, setAutoConfirmMode] = useState(true)
|
||||
const [ocStore, setOcStore] = useState(loadOC) // 偏好学习(localStorage 持久化)
|
||||
const [titleHighlight, setTitleHighlight] = useState(false)
|
||||
const [introHighlight, setIntroHighlight] = useState(false)
|
||||
const [ocModal, setOcModal] = useState({
|
||||
open: false, view: 'config', // config | progress | done
|
||||
dest: '云南(昆明/大理/丽江)', days: '6天5晚', type: '跟团游', theme: '亲子家庭',
|
||||
steps: [], learnVisible: false, learnHtml: '', doneHtml: ''
|
||||
})
|
||||
|
||||
/* ---------------- Ref 镜像(供异步流读取最新值) ---------------- */
|
||||
const formRef = useRef(INITIAL_FORM)
|
||||
formRef.current = form
|
||||
const generatedRef = useRef({}) // 每类工具最新生成结果 { data, used }
|
||||
const serialRef = useRef(null) // { resolve, msgId, index, type, mode }
|
||||
const autoConfirmRef = useRef(true)
|
||||
const silentBatchRef = useRef(false) // 一键发品批量模式:静默填入
|
||||
const detailRef = useRef(null) // 商品详情 contenteditable DOM
|
||||
const formAreaRef = useRef(null) // 左侧表单滚动容器
|
||||
const sectionRefs = useRef({}) // 表单卡片锚点 { 'card-title': HTMLElement }
|
||||
const timerRef = useRef(null)
|
||||
const ocStoreRef = useRef(ocStore)
|
||||
ocStoreRef.current = ocStore
|
||||
const ocModalRef = useRef(ocModal)
|
||||
ocModalRef.current = ocModal
|
||||
|
||||
/* ---------------- 基础动作 ---------------- */
|
||||
const showToast = useCallback(text => {
|
||||
if (silentBatchRef.current) return
|
||||
setToastState({ text: '✓ ' + text, ts: Date.now() })
|
||||
}, [])
|
||||
|
||||
const flashSection = useCallback(cardId => {
|
||||
if (silentBatchRef.current) return
|
||||
const el = sectionRefs.current[cardId]
|
||||
if (!el) return
|
||||
el.classList.add('section-highlight')
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
setTimeout(() => el.classList.remove('section-highlight'), 1800)
|
||||
}, [])
|
||||
|
||||
/** 读取当前表单上下文(生成依赖表单配置) */
|
||||
const readForm = useCallback(() => {
|
||||
const f = formRef.current
|
||||
return {
|
||||
dest: f.dest.replace(/(.*)/, '') || '云南',
|
||||
days: f.days || '6天5晚',
|
||||
type: f.type || '跟团游',
|
||||
theme: f.theme || '亲子家庭'
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ---------------- 消息流 ---------------- */
|
||||
const addMsg = useCallback((role, html) => {
|
||||
const id = 'm' + (++msgSeq)
|
||||
setMessages(ms => [...ms, { id, role, kind: 'html', html }])
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const addTyping = useCallback(() => {
|
||||
const id = 'm' + (++msgSeq)
|
||||
setMessages(ms => [...ms, { id, role: 'ai', kind: 'typing' }])
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const patchMsg = useCallback((id, patch) => {
|
||||
setMessages(ms => ms.map(m => (m.id === id ? { ...m, ...patch } : m)))
|
||||
}, [])
|
||||
|
||||
/** 替换 typing 占位为正式内容 */
|
||||
const replaceTyping = useCallback((id, patch) => {
|
||||
patchMsg(id, { kind: patch.kind || 'html', ...patch })
|
||||
}, [patchMsg])
|
||||
|
||||
/* ---------------- 表单写入(applyMap 的 React 版) ---------------- */
|
||||
const applyGen = useCallback(type => {
|
||||
const gen = generatedRef.current[type]
|
||||
if (!gen) return
|
||||
switch (type) {
|
||||
case 'title': {
|
||||
const opts = gen.data
|
||||
const idx = gen.used || 0
|
||||
const t = opts[idx % opts.length]
|
||||
generatedRef.current[type] = { ...gen, used: idx + 1 }
|
||||
formRef.current = { ...formRef.current, title: t }
|
||||
setForm(f => ({ ...f, title: t }))
|
||||
setTitleHighlight(true)
|
||||
setTimeout(() => setTitleHighlight(false), 1600)
|
||||
flashSection('card-title')
|
||||
showToast('已填入商品标题(方案 ' + ((idx % opts.length) + 1) + ')')
|
||||
break
|
||||
}
|
||||
case 'intro': {
|
||||
formRef.current = { ...formRef.current, intro: gen.data }
|
||||
setForm(f => ({ ...f, intro: gen.data }))
|
||||
setIntroHighlight(true)
|
||||
setTimeout(() => setIntroHighlight(false), 1600)
|
||||
flashSection('card-intro')
|
||||
showToast('已填入详细介绍')
|
||||
break
|
||||
}
|
||||
case 'detail': {
|
||||
if (detailRef.current) detailRef.current.innerHTML = gen.data
|
||||
flashSection('card-detail')
|
||||
showToast('已生成商品详情图文结构')
|
||||
break
|
||||
}
|
||||
case 'image': {
|
||||
setImages(prev => [...prev, ...gen.data.map(im => ({ label: im.label, ai: true }))])
|
||||
flashSection('card-img')
|
||||
showToast('已按 AI 清单添加 ' + gen.data.length + ' 张配图')
|
||||
break
|
||||
}
|
||||
case 'itinerary': {
|
||||
setItineraryDays(gen.data.map(d => ({ ...d })))
|
||||
flashSection('card-itinerary')
|
||||
showToast('已填入 ' + gen.data.length + ' 天行程')
|
||||
break
|
||||
}
|
||||
case 'price': {
|
||||
setPriceRows(gen.data.map(r => ({ ...r })))
|
||||
flashSection('card-price')
|
||||
showToast('已生成 ' + gen.data.length + ' 个团期')
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [flashSection, showToast])
|
||||
|
||||
/* ---------------- 串行确认机制 ---------------- */
|
||||
const stopTimer = useCallback(() => {
|
||||
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null }
|
||||
}, [])
|
||||
|
||||
const startCountdown = useCallback(() => {
|
||||
if (!serialRef.current || !serialRef.current.mode) return
|
||||
stopTimer()
|
||||
let remaining = 5
|
||||
setCountdown(5)
|
||||
timerRef.current = setInterval(() => {
|
||||
remaining--
|
||||
if (remaining <= 0) {
|
||||
stopTimer()
|
||||
setCountdown(0)
|
||||
const s = serialRef.current
|
||||
serialRef.current = null
|
||||
if (s) s.resolve()
|
||||
} else {
|
||||
setCountdown(remaining)
|
||||
}
|
||||
}, 1000)
|
||||
}, [stopTimer])
|
||||
|
||||
const serialConfirm = useCallback(() => {
|
||||
const s = serialRef.current
|
||||
if (!s) return
|
||||
stopTimer()
|
||||
setCountdown(0)
|
||||
serialRef.current = null
|
||||
patchMsg(s.msgId, { confirm: { index: s.index, mode: s.mode, status: 'confirmed' } })
|
||||
s.resolve()
|
||||
}, [patchMsg, stopTimer])
|
||||
|
||||
const serialRegen = useCallback(() => {
|
||||
const s = serialRef.current
|
||||
if (!s) return
|
||||
stopTimer()
|
||||
const g = buildGen(s.type, readForm())
|
||||
generatedRef.current[s.type] = { data: g.data, used: 0 }
|
||||
patchMsg(s.msgId, { gen: { type: s.type, pre: g.pre, card: g.card } })
|
||||
if (s.mode) startCountdown()
|
||||
}, [patchMsg, readForm, startCountdown, stopTimer])
|
||||
|
||||
const setAutoConfirm = useCallback(val => {
|
||||
autoConfirmRef.current = val
|
||||
setAutoConfirmMode(val)
|
||||
const s = serialRef.current
|
||||
if (!s) return
|
||||
stopTimer()
|
||||
s.mode = val
|
||||
if (val) startCountdown()
|
||||
else setCountdown(0)
|
||||
}, [startCountdown, stopTimer])
|
||||
|
||||
/** 串行流程的单步:生成卡片 → 等待确认(Promise) */
|
||||
const showStepCard = useCallback(async (taskId, index, type, autoMode) => {
|
||||
const tp = addTyping()
|
||||
await delay(500 + Math.random() * 400)
|
||||
const g = buildGen(type, readForm())
|
||||
generatedRef.current[type] = { data: g.data, used: 0 }
|
||||
patchMsg(tp, {
|
||||
kind: 'gen',
|
||||
gen: { type, pre: g.pre, card: g.card },
|
||||
confirm: { taskId, index, mode: autoMode, status: 'waiting' }
|
||||
})
|
||||
await new Promise(resolve => {
|
||||
serialRef.current = { resolve, msgId: tp, taskId, index, type, mode: autoMode }
|
||||
if (autoMode) startCountdown()
|
||||
})
|
||||
}, [addTyping, patchMsg, readForm, startCountdown])
|
||||
|
||||
/* ---------------- 工具任务清单卡片 ---------------- */
|
||||
const addTaskCard = useCallback((title, ctxLine, steps) => {
|
||||
const id = 'm' + (++msgSeq)
|
||||
setMessages(ms => [...ms, {
|
||||
id, role: 'ai', kind: 'task',
|
||||
task: {
|
||||
title, ctxLine,
|
||||
steps: steps.map(s => ({ label: s.label, note: '等待中', status: 'todo' }))
|
||||
}
|
||||
}])
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const setTaskStep = useCallback((taskId, i, status, note) => {
|
||||
setMessages(ms => ms.map(m => {
|
||||
if (m.id !== taskId) return m
|
||||
const steps = m.task.steps.map((s, j) => (j === i ? { ...s, status, note: note ?? s.note } : s))
|
||||
return { ...m, task: { ...m.task, steps } }
|
||||
}))
|
||||
}, [])
|
||||
|
||||
/* ---------------- 快捷动作 / 对话编排 ---------------- */
|
||||
const quickAction = useCallback(async (type, silent) => {
|
||||
if (!silent) addMsg('user', '点击了「' + actionLabel[type] + '」')
|
||||
const tp = addTyping()
|
||||
await delay(900 + Math.random() * 600)
|
||||
const g = buildGen(type, readForm())
|
||||
generatedRef.current[type] = { data: g.data, used: 0 }
|
||||
replaceTyping(tp, { kind: 'gen', gen: { type, pre: g.pre, card: g.card } })
|
||||
}, [addMsg, addTyping, readForm, replaceTyping])
|
||||
|
||||
/** 表单模块内嵌 AI 按钮:面板收起时自动展开 */
|
||||
const formAI = useCallback(type => {
|
||||
setPanelCollapsed(false)
|
||||
quickAction(type)
|
||||
}, [quickAction])
|
||||
|
||||
const regen = useCallback(type => quickAction(type, true), [quickAction])
|
||||
|
||||
/** 把解析出的配置回写左侧表单 */
|
||||
const applyBriefToForm = useCallback(b => {
|
||||
const patch = {}
|
||||
if (b.dest) patch.dest = b.dest
|
||||
if (b.days) patch.days = b.days
|
||||
if (b.type) patch.type = b.type
|
||||
if (b.theme) patch.theme = b.theme
|
||||
if (Object.keys(patch).length) {
|
||||
formRef.current = { ...formRef.current, ...patch }
|
||||
setForm(f => ({ ...f, ...patch }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 记录一次发品,沉淀偏好(与一键发品共用记忆) */
|
||||
const recordPublish = useCallback((destShort, days, type, theme) => {
|
||||
const prefs = buildPrefs(destShort, days, type, theme)
|
||||
const prev = ocStoreRef.current
|
||||
const count = prev.count + 1
|
||||
const next = {
|
||||
count,
|
||||
prefs: prev.prefs.concat(prefs),
|
||||
last: {
|
||||
dest: destShort, days, type, theme,
|
||||
destSel: formRef.current.dest, daysSel: days, typeSel: type,
|
||||
styleWord: count >= 3 ? `${theme}友好·${count >= 10 ? '爆款结构复刻' : '您的惯用话术'}` : '纯玩无购物'
|
||||
}
|
||||
}
|
||||
ocStoreRef.current = next
|
||||
saveOC(next)
|
||||
setOcStore(next)
|
||||
return { prefs, count }
|
||||
}, [])
|
||||
|
||||
/** 自主发品 Agent:8 工具全量编排 */
|
||||
const agentRun = useCallback(async rawText => {
|
||||
const b = parseBrief(rawText)
|
||||
applyBriefToForm(b)
|
||||
|
||||
const f = readForm()
|
||||
const ctxLine = `${f.dest} · ${f.days} · ${f.type} · ${f.theme}`
|
||||
addMsg('ai', `🚀 好的,我来自主完成这次发品。<br>已识别配置:<b>${ctxLine}</b>${b.dest ? '(来自您的描述)' : '(沿用当前表单)'}。<br>我将依次调用 <b>8 个工具</b>,每个工具生成后<b>展示卡片等您确认</b>再填入表单:`)
|
||||
|
||||
const taskId = addTaskCard('🤖 自主发品任务清单', `📋 上下文:${ctxLine} | 预计 8 步`, agentSteps)
|
||||
|
||||
for (let i = 0; i < agentSteps.length; i++) {
|
||||
const s = agentSteps[i]
|
||||
setTaskStep(taskId, i, 'doing', '生成中…')
|
||||
await showStepCard(taskId, i, s.type, autoConfirmRef.current)
|
||||
applyGen(s.type)
|
||||
setTaskStep(taskId, i, 'done', s.note)
|
||||
}
|
||||
|
||||
// 沉淀偏好(越用越好用)
|
||||
const { prefs, count } = recordPublish(f.dest, f.days, f.type, f.theme)
|
||||
formAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
const lv = ocLevelInfo({ count, prefs: [], last: null })
|
||||
addMsg('ai', `✅ <b>自主发品完成!</b>6 大模块已全部填入左侧表单,另完成 SEO 关键词提取与合规扫描。<br>本次沉淀偏好 <b>${prefs.length} 条</b>,累计发品 <b>${count} 次</b>(${lv.name})。<br>可直接点顶部「提交审核」,或对任一模块点「🔄 换一版」微调。`)
|
||||
}, [addMsg, addTaskCard, applyBriefToForm, applyGen, readForm, recordPublish, setTaskStep, showStepCard])
|
||||
|
||||
/** 多工具串行编排:仅跑用户指定的模块 */
|
||||
const multiToolRun = useCallback(async (rawText, toolTypes) => {
|
||||
const b = parseBrief(rawText)
|
||||
if (Object.keys(b).length) applyBriefToForm(b)
|
||||
|
||||
const f = readForm()
|
||||
const ctxLine = `${f.dest} · ${f.days} · ${f.type} · ${f.theme}`
|
||||
const steps = agentSteps.filter(s => toolTypes.includes(s.type))
|
||||
const labels = steps.map(s => s.label).join(' → ')
|
||||
|
||||
addMsg('ai', `🛠️ 好的,我来自主调度 <b>${steps.length}</b> 个模块。<br>已识别上下文:<b>${ctxLine}</b>${b.dest ? '(来自您的描述)' : '(沿用当前表单)'}。<br>将依次执行:<b>${labels}</b>,每个工具生成后<b>展示卡片等您确认</b>再填入表单:`)
|
||||
|
||||
const taskId = addTaskCard('🛠️ 多工具串行任务', `📋 ${ctxLine} | 共 ${steps.length} 步`, steps)
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const s = steps[i]
|
||||
setTaskStep(taskId, i, 'doing', '生成中…')
|
||||
await showStepCard(taskId, i, s.type, autoConfirmRef.current)
|
||||
applyGen(s.type)
|
||||
setTaskStep(taskId, i, 'done', s.note)
|
||||
}
|
||||
|
||||
const first = cardMap[steps[0].type]
|
||||
if (first) sectionRefs.current[first]?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
addMsg('ai', `✅ <b>${steps.length} 个模块生成完毕!</b>${steps.map(s => s.label).join('、')} 已填入左侧表单。<br>不满意可点对应模块的「🔄 换一版」微调,或说「<b>帮我把这个商品发布了</b>」让我一口气完成全部模块。`)
|
||||
}, [addMsg, addTaskCard, applyBriefToForm, applyGen, readForm, setTaskStep, showStepCard])
|
||||
|
||||
/** 自由对话入口:意图识别 → 多工具编排 / 全量 Agent / 单工具 / 闲聊 */
|
||||
const sendMessage = useCallback(async text => {
|
||||
text = (text || '').trim()
|
||||
if (!text) return
|
||||
// 若有串行步骤在等待确认,自动确认后继续处理新消息
|
||||
if (serialRef.current) serialConfirm()
|
||||
addMsg('user', esc(text))
|
||||
const tp = addTyping()
|
||||
await delay(700)
|
||||
// 优先:多工具串行(用户同时提到 2+ 模块)
|
||||
const tools = parseMultiTools(text)
|
||||
if (tools.length >= 2) {
|
||||
replaceTyping(tp, { html: `明白,我来自主调度 ${tools.length} 个模块 🛠️` })
|
||||
await delay(400)
|
||||
multiToolRun(text, tools)
|
||||
return
|
||||
}
|
||||
// 全量自主发品
|
||||
if (agentIntentRe.test(text)) {
|
||||
replaceTyping(tp, { html: '明白,我来自主完成这次发品 🚀' })
|
||||
await delay(400)
|
||||
agentRun(text)
|
||||
return
|
||||
}
|
||||
const hit = CANNED.find(c => c.k.some(k => text.toLowerCase().includes(k)))
|
||||
if (hit) {
|
||||
replaceTyping(tp, { html: '好的,马上为您处理 👇' })
|
||||
await delay(400)
|
||||
quickAction(hit.type)
|
||||
return
|
||||
}
|
||||
const ctx = readForm()
|
||||
const replies = [
|
||||
`收到!我已读取当前表单:目的地 <span class="hl">${ctx.dest}</span>、类型 <span class="hl">${ctx.type}</span>。您可以点上方快捷按钮生成内容,或直接说需求,例如:<br>·「帮我写个适合${ctx.theme}的标题」<br>·「国庆团期怎么定价」`,
|
||||
`关于「${esc(text.slice(0, 12))}${text.length > 12 ? '…' : ''}」,建议先在左侧表单选好目的地和天数,我生成的内容会更精准。也可以试试快捷区的 <b>价格库存</b>,我会基于市场数据给出排期方案~`,
|
||||
`我可以帮您拆解:先定目标人群(${ctx.theme}?蜜月?),再定核心卖点(纯玩/性价比/体验感),然后按框架生成标题→介绍→详情→行程。直接点快捷按钮即可开始 ✨`
|
||||
]
|
||||
replaceTyping(tp, { html: replies[Math.floor(Math.random() * replies.length)] })
|
||||
}, [addMsg, addTyping, agentRun, multiToolRun, quickAction, readForm, replaceTyping, serialConfirm])
|
||||
|
||||
/* ---------------- 一键发品(越用越好用) ---------------- */
|
||||
const openOC = useCallback(() => {
|
||||
const store = ocStoreRef.current
|
||||
const last = store.last
|
||||
setOcModal(prev => {
|
||||
const next = { ...prev, open: true, view: 'config', steps: [], learnVisible: false, learnHtml: '', doneHtml: '' }
|
||||
if (last) {
|
||||
if (last.destSel) next.dest = last.destSel
|
||||
if (last.daysSel) next.days = last.daysSel
|
||||
if (last.typeSel) next.type = last.typeSel
|
||||
if (last.theme) next.theme = last.theme
|
||||
}
|
||||
if (store.count > 0 && last) {
|
||||
next.learnVisible = true
|
||||
next.learnHtml = `🧠 <b>越用越好用</b>:已根据您最近 ${store.count} 次发品习惯<b>自动预填</b>(${last.dest}·${last.days}·${last.type}·${last.theme}),生成内容将沿用您偏好的「${last.styleWord}」风格。`
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const closeOC = useCallback(() => {
|
||||
setOcModal(prev => ({ ...prev, open: false }))
|
||||
}, [])
|
||||
|
||||
const setOCField = useCallback((k, v) => {
|
||||
setOcModal(prev => ({ ...prev, [k]: v }))
|
||||
}, [])
|
||||
|
||||
const startOC = useCallback(async () => {
|
||||
const cfg = ocModalRef.current
|
||||
const { dest: destSel, days: daysSel, type: typeSel, theme } = cfg
|
||||
// 同步配置到左侧表单(生成依赖表单上下文)
|
||||
applyBriefToForm({ dest: destSel, days: daysSel, type: typeSel, theme })
|
||||
setOcModal(prev => ({ ...prev, view: 'progress', steps: ocStepsDef.map(() => ({ status: 'todo' })) }))
|
||||
|
||||
silentBatchRef.current = true
|
||||
for (let i = 0; i < ocStepsDef.length; i++) {
|
||||
const s = ocStepsDef[i]
|
||||
setOcModal(prev => ({ ...prev, steps: prev.steps.map((x, j) => (j === i ? { status: 'doing' } : x)) }))
|
||||
await delay(650 + Math.random() * 450)
|
||||
const g = buildGen(s.type, readForm())
|
||||
generatedRef.current[s.type] = { data: g.data, used: 0 }
|
||||
applyGen(s.type)
|
||||
setOcModal(prev => ({ ...prev, steps: prev.steps.map((x, j) => (j === i ? { status: 'done' } : x)) }))
|
||||
}
|
||||
silentBatchRef.current = false
|
||||
|
||||
// 沉淀偏好(越用越好用的核心)
|
||||
const dest = destSel.replace(/(.*)/, '')
|
||||
const { prefs, count } = recordPublish(dest, daysSel, typeSel, theme)
|
||||
const lv = ocLevelInfo({ count, prefs: [], last: null })
|
||||
const doneHtml = `🧠 <b>本次为您沉淀 ${prefs.length} 条偏好</b>(累计 ${ocStoreRef.current.prefs.length} 条):<br>` +
|
||||
prefs.map(p => '· ' + p).join('<br>') +
|
||||
`<br><b>${lv.name}</b> · ${lv.next}<br>下次一键发品将自动预填并按您的风格生成。`
|
||||
setOcModal(prev => ({ ...prev, view: 'done', doneHtml }))
|
||||
|
||||
showToast('6 大模块已生成完毕')
|
||||
addMsg('ai', `已完成 <b>一键发品</b> ✅ 标题、介绍、详情、配图、行程、价格库存全部就位。<br>本次沉淀偏好 <b>${ocStoreRef.current.prefs.length} 条</b>,累计发品 <b>${count} 次</b>——用得越多,生成越贴合您的风格。<br>任何模块不满意,点对应快捷按钮「🔄 换一版」即可。`)
|
||||
}, [addMsg, applyBriefToForm, applyGen, readForm, recordPublish, showToast])
|
||||
|
||||
/* ---------------- 表单交互 ---------------- */
|
||||
const updateForm = useCallback((k, v) => {
|
||||
formRef.current = { ...formRef.current, [k]: v }
|
||||
setForm(f => ({ ...f, [k]: v }))
|
||||
}, [])
|
||||
|
||||
const uploadImg = useCallback(() => {
|
||||
setImages(prev => [...prev, { label: '新上传图片', ai: false }])
|
||||
showToast('已添加图片占位(演示)')
|
||||
}, [showToast])
|
||||
|
||||
const removeImage = useCallback(i => setImages(prev => prev.filter((_, j) => j !== i)), [])
|
||||
|
||||
const addDay = useCallback(() => setItineraryDays(prev => [...prev, { title: '', desc: '' }]), [])
|
||||
const removeDay = useCallback(i => setItineraryDays(prev => prev.filter((_, j) => j !== i)), [])
|
||||
const updateDay = useCallback((i, k, v) => {
|
||||
setItineraryDays(prev => prev.map((d, j) => (j === i ? { ...d, [k]: v } : d)))
|
||||
}, [])
|
||||
|
||||
const addPriceRow = useCallback(() => setPriceRows(prev => [...prev, { date: '2026-10-01', adult: 3299, child: 2299, stock: 20, on: true }]), [])
|
||||
const removePriceRow = useCallback(i => setPriceRows(prev => prev.filter((_, j) => j !== i)), [])
|
||||
const updatePriceRow = useCallback((i, k, v) => {
|
||||
setPriceRows(prev => prev.map((r, j) => (j === i ? { ...r, [k]: v } : r)))
|
||||
}, [])
|
||||
const togglePriceOn = useCallback(i => {
|
||||
setPriceRows(prev => prev.map((r, j) => (j === i ? { ...r, on: !r.on } : r)))
|
||||
}, [])
|
||||
|
||||
/* ---------------- 初始欢迎消息 ---------------- */
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
const tp1 = addTyping()
|
||||
await delay(700)
|
||||
if (!alive) return
|
||||
replaceTyping(tp1, { html: WELCOME_1 })
|
||||
await delay(600)
|
||||
if (!alive) return
|
||||
const tp2 = addTyping()
|
||||
await delay(900)
|
||||
if (!alive) return
|
||||
replaceTyping(tp2, { html: WELCOME_2 })
|
||||
})()
|
||||
return () => { alive = false }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
form, images, itineraryDays, priceRows, messages, panelCollapsed,
|
||||
toastState, countdown, autoConfirmMode, ocStore, ocModal,
|
||||
titleHighlight, introHighlight,
|
||||
// refs(组件注册 DOM 用)
|
||||
detailRef, sectionRefs, formAreaRef,
|
||||
// 动作
|
||||
updateForm, uploadImg, removeImage,
|
||||
addDay, removeDay, updateDay,
|
||||
addPriceRow, removePriceRow, updatePriceRow, togglePriceOn,
|
||||
setPanelCollapsed, applyGen, regen, formAI, quickAction,
|
||||
sendMessage, setAutoConfirm, serialConfirm, serialRegen,
|
||||
openOC, closeOC, setOCField, startOC,
|
||||
showToast
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
/*
|
||||
* 旅智工作台 · 商品发布 AI 助手 —— 全局样式
|
||||
* 从 v1 单文件版(根目录 index.html)完整迁移,类名与选择器保持不变,
|
||||
* 保证 React 版与原始 Demo 视觉/交互完全一致。
|
||||
*/
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||
background: #f0f2f5; color: #1f2329;
|
||||
height: 100vh; overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
/* ============ 顶部导航 ============ */
|
||||
.topbar {
|
||||
height: 52px; background: #fff; border-bottom: 1px solid #e5e6eb;
|
||||
display: flex; align-items: center; padding: 0 20px; flex-shrink: 0; z-index: 10;
|
||||
}
|
||||
.topbar .logo { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 15px; color: #d96a00; }
|
||||
.topbar .logo .icon {
|
||||
width: 26px; height: 26px; background: linear-gradient(135deg, #ff7a1a, #ffb300);
|
||||
border-radius: 7px; display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 14px;
|
||||
}
|
||||
.topbar .breadcrumb { margin-left: 24px; font-size: 13px; color: #86909c; }
|
||||
.topbar .breadcrumb b { color: #1f2329; font-weight: 500; }
|
||||
.topbar .right { margin-left: auto; display: flex; align-items: center; gap: 16px; font-size: 13px; color: #4e5969; }
|
||||
.avatar-sm {
|
||||
width: 30px; height: 30px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 12px;
|
||||
}
|
||||
|
||||
/* ============ 主体布局 ============ */
|
||||
.main { flex: 1; display: flex; overflow: hidden; }
|
||||
|
||||
/* ============ 左侧:表单区 ============ */
|
||||
.form-area { flex: 1; overflow-y: auto; padding: 20px 24px 60px; }
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.page-head h1 { font-size: 20px; font-weight: 600; }
|
||||
.page-head .sub { font-size: 13px; color: #86909c; margin-top: 4px; }
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #ff7a1a, #ff6600); color: #fff; border: none;
|
||||
padding: 9px 22px; border-radius: 8px; font-size: 14px; cursor: pointer;
|
||||
font-weight: 500; transition: opacity .2s;
|
||||
}
|
||||
.btn-primary:hover { opacity: .88; }
|
||||
|
||||
.card {
|
||||
background: #fff; border-radius: 12px; border: 1px solid #e5e6eb;
|
||||
padding: 24px; margin-bottom: 16px;
|
||||
}
|
||||
.card h2 { font-size: 15px; font-weight: 600; margin-bottom: 18px; display: flex; align-items: center; gap: 8px; }
|
||||
.card h2 .dot { width: 4px; height: 14px; background: #ff7a1a; border-radius: 2px; }
|
||||
.card h2 .badge-step {
|
||||
margin-left: auto; font-size: 11px; font-weight: 500; color: #86909c;
|
||||
background: #f7f8fa; border-radius: 12px; padding: 3px 10px;
|
||||
}
|
||||
/* 表单模块内嵌 AI 按钮 */
|
||||
.ai-inline-btn {
|
||||
margin-left: 8px; font-size: 12px; font-weight: 500;
|
||||
color: #ff7a1a; background: #fff; border: 1px solid rgba(255, 122, 26, 0.5);
|
||||
border-radius: 12px; padding: 3px 10px; cursor: pointer;
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
transition: all .15s ease; white-space: nowrap;
|
||||
}
|
||||
.ai-inline-btn:hover {
|
||||
background: #ff7a1a; color: #fff; border-color: #ff7a1a;
|
||||
box-shadow: 0 2px 8px rgba(255, 122, 26, 0.35);
|
||||
}
|
||||
.form-row { display: flex; gap: 16px; margin-bottom: 16px; }
|
||||
.form-item { flex: 1; }
|
||||
.form-item:last-child { margin-bottom: 0; }
|
||||
.form-item label { display: block; font-size: 13px; color: #4e5969; margin-bottom: 6px; }
|
||||
.form-item label .req { color: #f53f3f; margin-right: 2px; }
|
||||
.form-item input, .form-item select, .form-item textarea {
|
||||
width: 100%; border: 1px solid #e5e6eb; border-radius: 8px; padding: 9px 12px;
|
||||
font-size: 13px; background: #fff; transition: border-color .2s, box-shadow .2s; font-family: inherit;
|
||||
}
|
||||
.form-item input:focus, .form-item select:focus, .form-item textarea:focus {
|
||||
outline: none; border-color: #ff7a1a; box-shadow: 0 0 0 3px rgba(255,122,26,.12);
|
||||
}
|
||||
.form-item textarea { resize: vertical; min-height: 92px; line-height: 1.7; }
|
||||
.form-item .hint { font-size: 12px; color: #c9cdd4; margin-top: 4px; }
|
||||
.counter { font-size: 12px; color: #c9cdd4; margin-top: 4px; text-align: right; }
|
||||
.field-highlight { border-color: #ff7a1a !important; box-shadow: 0 0 0 3px rgba(255,122,26,.18) !important; }
|
||||
.section-highlight { outline: 2px solid #ff7a1a; outline-offset: 2px; border-radius: 12px; }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.chip {
|
||||
border: 1px solid #e5e6eb; border-radius: 16px; padding: 5px 14px; font-size: 12px;
|
||||
color: #4e5969; cursor: pointer; transition: all .2s; background: #fff;
|
||||
}
|
||||
.chip:hover { border-color: #ff7a1a; color: #d96a00; }
|
||||
.chip.active { background: rgba(255,122,26,.08); border-color: #ff7a1a; color: #d96a00; }
|
||||
|
||||
/* 富文本编辑器 */
|
||||
.rich-editor { border: 1px solid #e5e6eb; border-radius: 8px; overflow: hidden; }
|
||||
.rich-toolbar {
|
||||
display: flex; gap: 2px; padding: 6px 8px; border-bottom: 1px solid #f2f3f5; background: #fafbfc;
|
||||
}
|
||||
.rich-toolbar button {
|
||||
border: none; background: transparent; width: 28px; height: 26px; border-radius: 6px;
|
||||
cursor: pointer; font-size: 13px; color: #4e5969;
|
||||
}
|
||||
.rich-toolbar button:hover { background: #eef1f4; }
|
||||
.rich-toolbar .sep { width: 1px; background: #e5e6eb; margin: 3px 4px; }
|
||||
.rich-content {
|
||||
min-height: 150px; padding: 12px 14px; font-size: 13px; line-height: 1.9; outline: none;
|
||||
}
|
||||
.rich-content:empty::before { content: attr(data-placeholder); color: #c9cdd4; }
|
||||
.rich-content h3 { font-size: 14px; margin: 10px 0 6px; color: #d96a00; }
|
||||
.rich-content ul { padding-left: 20px; }
|
||||
.rich-content li { margin-bottom: 4px; }
|
||||
.rich-content .img-slot {
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
height: 60px; border-radius: 8px; margin: 8px 0; font-size: 12px; color: #86909c;
|
||||
background: repeating-linear-gradient(45deg, #f7f8fa, #f7f8fa 10px, #f2f3f5 10px, #f2f3f5 20px);
|
||||
border: 1px dashed #d5d9e0;
|
||||
}
|
||||
|
||||
/* 图片上传 */
|
||||
.img-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.img-tile {
|
||||
aspect-ratio: 4/3; border-radius: 10px; position: relative; overflow: hidden;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 6px; color: #fff; font-size: 13px; cursor: default;
|
||||
}
|
||||
.img-tile .em { font-size: 26px; }
|
||||
.img-tile .tag {
|
||||
position: absolute; top: 6px; left: 6px; font-size: 10px; background: rgba(0,0,0,.35);
|
||||
padding: 2px 7px; border-radius: 10px; backdrop-filter: blur(2px);
|
||||
}
|
||||
.img-tile .ai-tag {
|
||||
position: absolute; top: 6px; right: 6px; font-size: 10px; background: rgba(255,122,26,.9);
|
||||
padding: 2px 7px; border-radius: 10px;
|
||||
}
|
||||
.img-tile .del {
|
||||
position: absolute; bottom: 6px; right: 6px; width: 20px; height: 20px; border-radius: 50%;
|
||||
background: rgba(0,0,0,.4); color: #fff; border: none; font-size: 11px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.img-upload {
|
||||
aspect-ratio: 4/3; border: 1.5px dashed #c9cdd4; border-radius: 10px; display: flex;
|
||||
flex-direction: column; align-items: center; justify-content: center; gap: 6px;
|
||||
color: #86909c; font-size: 12px; cursor: pointer; transition: all .2s; background: #fafbfc;
|
||||
}
|
||||
.img-upload:hover { border-color: #ff7a1a; color: #d96a00; }
|
||||
.img-upload .plus { font-size: 24px; color: #c9cdd4; }
|
||||
|
||||
/* 行程编辑 */
|
||||
.day-card { border: 1px solid #e5e6eb; border-radius: 10px; margin-bottom: 10px; overflow: hidden; }
|
||||
.day-head {
|
||||
display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: #fafbfc;
|
||||
border-bottom: 1px solid #f2f3f5;
|
||||
}
|
||||
.day-head .dnum {
|
||||
background: linear-gradient(135deg, #ff7a1a, #ffb300); color: #fff; font-size: 12px;
|
||||
font-weight: 600; border-radius: 6px; padding: 3px 10px; flex-shrink: 0;
|
||||
}
|
||||
.day-head input {
|
||||
flex: 1; border: none; outline: none; font-size: 13px; background: transparent; font-family: inherit;
|
||||
}
|
||||
.day-head .del-day {
|
||||
border: none; background: transparent; color: #c9cdd4; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.day-head .del-day:hover { color: #f53f3f; }
|
||||
.day-body { padding: 10px 12px; }
|
||||
.day-body textarea {
|
||||
width: 100%; border: none; outline: none; resize: vertical; font-size: 12px; line-height: 1.7;
|
||||
min-height: 44px; font-family: inherit; background: transparent; color: #4e5969;
|
||||
}
|
||||
.add-day {
|
||||
width: 100%; border: 1.5px dashed #c9cdd4; background: #fafbfc; border-radius: 10px;
|
||||
padding: 10px; color: #86909c; font-size: 13px; cursor: pointer; transition: all .2s;
|
||||
}
|
||||
.add-day:hover { border-color: #ff7a1a; color: #d96a00; }
|
||||
|
||||
/* 价格库存表 */
|
||||
.price-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.price-table th {
|
||||
text-align: left; padding: 8px 10px; background: #fafbfc; color: #86909c;
|
||||
font-weight: 500; font-size: 12px; border-bottom: 1px solid #f2f3f5;
|
||||
}
|
||||
.price-table td { padding: 6px 8px; border-bottom: 1px solid #f7f8fa; }
|
||||
.price-table input {
|
||||
width: 100%; border: 1px solid transparent; border-radius: 6px; padding: 6px 8px;
|
||||
font-size: 13px; font-family: inherit; background: transparent;
|
||||
}
|
||||
.price-table input:hover, .price-table input:focus { border-color: #e5e6eb; background: #fff; outline: none; }
|
||||
.price-table input:focus { border-color: #ff7a1a; }
|
||||
.stock-low { color: #b45309; }
|
||||
.status-pill { font-size: 11px; border-radius: 10px; padding: 2px 9px; }
|
||||
.status-on { background: rgba(34,197,94,.1); color: #15803d; }
|
||||
.status-off { background: #f2f3f5; color: #86909c; }
|
||||
.del-row { border: none; background: transparent; color: #c9cdd4; cursor: pointer; font-size: 14px; }
|
||||
.del-row:hover { color: #f53f3f; }
|
||||
.empty-tip { padding: 18px; text-align: center; color: #c9cdd4; font-size: 13px; }
|
||||
|
||||
/* ============ 右侧:AI 助手 ============ */
|
||||
.ai-panel {
|
||||
width: 400px; background: #fff; border-left: 1px solid #e5e6eb;
|
||||
display: flex; flex-direction: column; position: relative;
|
||||
transition: margin-right .35s ease; flex-shrink: 0;
|
||||
}
|
||||
.ai-panel.collapsed { margin-right: -400px; }
|
||||
.ai-header {
|
||||
padding: 14px 16px; border-bottom: 1px solid #f2f3f5; display: flex; align-items: center; gap: 10px;
|
||||
background: linear-gradient(135deg, rgba(255,122,26,.05), rgba(255,178,0,.05)); flex-shrink: 0;
|
||||
}
|
||||
.ai-avatar {
|
||||
width: 38px; height: 38px; border-radius: 10px;
|
||||
background: linear-gradient(135deg, #ff7a1a, #ffb300);
|
||||
display: flex; align-items: center; justify-content: center; color: #fff; font-size: 18px;
|
||||
position: relative; flex-shrink: 0;
|
||||
}
|
||||
.ai-avatar::after {
|
||||
content: ""; position: absolute; right: -2px; bottom: -2px; width: 10px; height: 10px;
|
||||
background: #22c55e; border: 2px solid #fff; border-radius: 50%;
|
||||
}
|
||||
.ai-header .info { flex: 1; }
|
||||
.ai-header .name { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
||||
.ai-header .name .badge {
|
||||
font-size: 10px; background: rgba(255,122,26,.1); color: #d96a00;
|
||||
padding: 2px 6px; border-radius: 4px; font-weight: 500;
|
||||
}
|
||||
.ai-header .status { font-size: 12px; color: #86909c; margin-top: 2px; }
|
||||
.icon-btn {
|
||||
width: 30px; height: 30px; border: 1px solid #e5e6eb; background: #fff; border-radius: 8px;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
color: #86909c; font-size: 14px; transition: all .2s;
|
||||
}
|
||||
.icon-btn:hover { color: #d96a00; border-color: #ff7a1a; }
|
||||
|
||||
.ai-quick { padding: 12px 14px 4px; border-bottom: 1px solid #f2f3f5; flex-shrink: 0; }
|
||||
.ai-quick .title { font-size: 12px; color: #86909c; margin-bottom: 8px; }
|
||||
.quick-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; padding-bottom: 10px; }
|
||||
.quick-btn {
|
||||
border: 1px solid #e5e6eb; border-radius: 10px; background: #fff; padding: 9px 2px;
|
||||
cursor: pointer; text-align: center; transition: all .2s;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 5px;
|
||||
}
|
||||
.quick-btn .qicon { width: 30px; height: 30px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 15px; }
|
||||
.quick-btn .qtext { font-size: 12px; color: #4e5969; }
|
||||
.quick-btn:hover { border-color: #ff7a1a; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(255,122,26,.12); }
|
||||
.q1 { background: rgba(255,122,26,.1); }
|
||||
.q2 { background: rgba(255,178,0,.1); }
|
||||
.q3 { background: rgba(245,158,11,.12); }
|
||||
.q4 { background: rgba(139,92,246,.1); }
|
||||
.q5 { background: rgba(236,72,153,.08); }
|
||||
.q6 { background: rgba(34,197,94,.1); }
|
||||
.q7 { background: rgba(99,102,241,.1); }
|
||||
|
||||
.ai-messages { flex: 1; overflow-y: auto; padding: 16px 14px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.msg { display: flex; gap: 8px; max-width: 100%; animation: msgIn .3s ease; }
|
||||
@keyframes msgIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.msg .m-avatar {
|
||||
width: 28px; height: 28px; border-radius: 8px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 13px; color: #fff;
|
||||
}
|
||||
.msg.ai .m-avatar { background: linear-gradient(135deg, #ff7a1a, #ffb300); }
|
||||
.msg.user { flex-direction: row-reverse; }
|
||||
.msg.user .m-avatar { background: linear-gradient(135deg, #6366f1, #8b5cf6); }
|
||||
.msg .bubble {
|
||||
background: #f7f8fa; border-radius: 12px; padding: 10px 12px; font-size: 13px;
|
||||
line-height: 1.75; color: #1f2329; max-width: calc(100% - 40px); word-break: break-word;
|
||||
}
|
||||
.msg.user .bubble { background: linear-gradient(135deg, #ff7a1a, #ff6600); color: #fff; }
|
||||
.bubble b { font-weight: 600; }
|
||||
.bubble .hl { color: #d96a00; font-weight: 600; }
|
||||
|
||||
.gen-card {
|
||||
background: #fff; border: 1px solid #ffe3c2; border-radius: 12px; padding: 12px;
|
||||
margin-top: 8px; box-shadow: 0 2px 8px rgba(15,23,42,.04);
|
||||
}
|
||||
.gen-card .gc-head { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #d96a00; font-weight: 600; margin-bottom: 8px; }
|
||||
.gen-card .gc-body { font-size: 13px; line-height: 1.8; color: #1f2329; background: #f8fafb; border-radius: 8px; padding: 10px 12px; max-height: 260px; overflow-y: auto; }
|
||||
.gen-card .gc-body .line { display: block; }
|
||||
.gen-card .gc-body .line b { color: #d96a00; }
|
||||
.gc-foot { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.gc-btn {
|
||||
flex: 1; border: 1px solid #ff7a1a; background: rgba(255,122,26,.06); color: #d96a00;
|
||||
border-radius: 8px; padding: 7px 0; font-size: 12px; cursor: pointer; transition: all .2s; font-weight: 500;
|
||||
}
|
||||
.gc-btn:hover { background: #ff7a1a; color: #fff; }
|
||||
.gc-btn.ghost { border-color: #e5e6eb; background: #fff; color: #4e5969; }
|
||||
.gc-btn.ghost:hover { border-color: #ff7a1a; color: #d96a00; background: #fff; }
|
||||
|
||||
.tips-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.tip-pill { font-size: 11px; background: rgba(255,178,0,.08); color: #d97706; border-radius: 12px; padding: 3px 10px; }
|
||||
.warn-pill { background: rgba(245,158,11,.1); color: #b45309; }
|
||||
.ok-pill { background: rgba(34,197,94,.1); color: #15803d; }
|
||||
|
||||
.typing { display: inline-flex; gap: 4px; padding: 4px 2px; }
|
||||
.typing span { width: 6px; height: 6px; background: #94a3b8; border-radius: 50%; animation: blink 1.2s infinite; }
|
||||
.typing span:nth-child(2) { animation-delay: .2s; }
|
||||
.typing span:nth-child(3) { animation-delay: .4s; }
|
||||
@keyframes blink { 0%, 80%, 100% { opacity: .3; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
|
||||
|
||||
.ai-input-area { border-top: 1px solid #f2f3f5; padding: 12px 14px; flex-shrink: 0; background: #fff; }
|
||||
.input-wrap { border: 1px solid #e5e6eb; border-radius: 12px; padding: 8px 8px 8px 12px; transition: border-color .2s, box-shadow .2s; }
|
||||
.input-wrap:focus-within { border-color: #ff7a1a; box-shadow: 0 0 0 3px rgba(255,122,26,.1); }
|
||||
.input-wrap textarea {
|
||||
width: 100%; border: none; outline: none; resize: none; font-size: 13px; line-height: 1.6;
|
||||
max-height: 90px; font-family: inherit; background: transparent;
|
||||
}
|
||||
.input-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 6px; }
|
||||
.input-tools { display: flex; gap: 4px; }
|
||||
.tool-btn {
|
||||
width: 28px; height: 28px; border: none; background: transparent; border-radius: 6px;
|
||||
cursor: pointer; color: #86909c; font-size: 14px; transition: all .2s;
|
||||
}
|
||||
.tool-btn:hover { background: #f2f3f5; color: #d96a00; }
|
||||
.send-btn {
|
||||
background: linear-gradient(135deg, #ff7a1a, #ff6600); border: none; color: #fff;
|
||||
width: 60px; height: 30px; border-radius: 8px; font-size: 13px; cursor: pointer;
|
||||
transition: opacity .2s; font-weight: 500;
|
||||
}
|
||||
.send-btn:hover { opacity: .88; }
|
||||
.input-hint { text-align: center; font-size: 11px; color: #c9cdd4; margin-top: 8px; }
|
||||
|
||||
.ai-float {
|
||||
position: absolute; right: 0; top: 50%; transform: translateY(-50%);
|
||||
width: 36px; height: 92px; background: linear-gradient(135deg, #ff7a1a, #ffb300);
|
||||
border-radius: 10px 0 0 10px; color: #fff; border: none; cursor: pointer; font-size: 13px;
|
||||
writing-mode: vertical-lr; letter-spacing: 3px; display: none; z-index: 20;
|
||||
box-shadow: -4px 0 12px rgba(255,122,26,.25);
|
||||
}
|
||||
.ai-panel.collapsed .ai-float { display: block; }
|
||||
|
||||
.toast {
|
||||
position: fixed; top: 68px; left: 50%; transform: translateX(-50%) translateY(-12px);
|
||||
background: #1f2329; color: #fff; padding: 9px 20px; border-radius: 8px; font-size: 13px;
|
||||
opacity: 0; pointer-events: none; transition: all .3s; z-index: 999;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-thumb { background: #d5d9e0; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #b8bec9; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
/* ============ 自主发品 Agent 卡片 ============ */
|
||||
.agent-card {
|
||||
border: 1px solid rgba(255, 122, 26, 0.45); border-radius: 12px; overflow: hidden;
|
||||
background: linear-gradient(180deg, #fffaf3, #fff); margin-top: 4px;
|
||||
}
|
||||
.ag-head { background: linear-gradient(135deg, #ffa200, #ff6600); color: #fff; padding: 9px 12px; font-size: 13px; font-weight: 600; }
|
||||
.ag-ctx { padding: 8px 12px; font-size: 12px; color: #d96a00; background: #fff7ed; border-bottom: 1px dashed rgba(255,122,26,.25); }
|
||||
.ag-steps { padding: 4px 0; }
|
||||
.ag-step { display: flex; align-items: center; gap: 8px; padding: 7px 12px; font-size: 12.5px; color: #4e5969; border-bottom: 1px solid #f7f3ee; }
|
||||
.ag-step:last-child { border-bottom: none; }
|
||||
.ag-step .ag-ico { width: 18px; text-align: center; font-size: 13px; flex-shrink: 0; }
|
||||
.ag-step .ag-lbl { flex: 1; }
|
||||
.ag-step .ag-note { font-size: 11px; color: #86909c; }
|
||||
.ag-step.todo .ag-note { color: #c0c4cc; }
|
||||
.ag-step.doing { color: #d96a00; background: #fff8ef; }
|
||||
.ag-step.doing .ag-ico { animation: agspin .8s linear infinite; display: inline-block; }
|
||||
.ag-step.doing .ag-note { color: #d96a00; }
|
||||
.ag-step.done .ag-lbl { color: #1f2329; }
|
||||
.ag-step.done .ag-ico { color: #22c55e; }
|
||||
@keyframes agspin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
|
||||
.ag-foot { padding: 8px 12px; font-size: 11px; color: #86909c; background: #fafafa; border-top: 1px solid #f0f0f0; }
|
||||
.ag-chip { display: inline-block; background: #fff; border: 1px solid rgba(255,122,26,.4); color: #ff7a1a; border-radius: 10px; padding: 1px 7px; font-size: 11px; margin: 0 3px 3px 0; }
|
||||
|
||||
/* ============ 串行确认栏 ============ */
|
||||
.ag-confirm-bar {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
margin-top: 10px; padding: 9px 12px;
|
||||
background: linear-gradient(135deg, #fff8ef, #fffaf3);
|
||||
border: 1px solid rgba(255,122,26,.3); border-radius: 8px;
|
||||
}
|
||||
.ag-cf-btn {
|
||||
background: linear-gradient(135deg, #ffa200, #ff6600); color: #fff; border: none;
|
||||
padding: 7px 18px; border-radius: 6px; font-size: 12.5px; cursor: pointer;
|
||||
font-weight: 600; font-family: inherit; transition: all .2s; white-space: nowrap;
|
||||
}
|
||||
.ag-cf-btn:hover { transform: translateY(-1px); box-shadow: 0 3px 10px rgba(255,122,26,.35); }
|
||||
.ag-regen-btn { padding: 7px 14px; font-size: 12px; border-radius: 6px; border: 1px solid #e5e6eb; background: #fff; color: #4e5969; cursor: pointer; font-family: inherit; transition: all .15s; white-space: nowrap; }
|
||||
.ag-regen-btn:hover { border-color: #ff7a1a; color: #d96a00; }
|
||||
.ag-cd { font-size: 11px; color: #d96a00; margin-left: auto; white-space: nowrap; }
|
||||
.ag-cd.manual { color: #86909c; }
|
||||
.ag-mode-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 7px 12px; background: #fff7ed; border-bottom: 1px solid rgba(255,122,26,.12);
|
||||
}
|
||||
.ag-mode-label { font-size: 11px; color: #86909c; }
|
||||
.ag-mt-btn {
|
||||
border: 1px solid rgba(255,122,26,.3); background: #fff; color: #d96a00;
|
||||
padding: 3px 10px; border-radius: 10px; font-size: 11px; cursor: pointer;
|
||||
font-family: inherit; transition: all .15s;
|
||||
}
|
||||
.ag-mt-btn.active { background: #ff7a1a; color: #fff; border-color: #ff7a1a; }
|
||||
.serial-card .gc-foot { display: none; }
|
||||
|
||||
/* ============ 一键发品 ============ */
|
||||
.btn-magic {
|
||||
background: linear-gradient(135deg, #ffa200, #ff6600); color: #fff; border: none;
|
||||
padding: 10px 26px; border-radius: 10px; font-size: 15px; cursor: pointer; font-weight: 600;
|
||||
box-shadow: 0 4px 14px rgba(255,122,26,.35); transition: all .2s; font-family: inherit;
|
||||
}
|
||||
.btn-magic:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(255,122,26,.45); }
|
||||
.btn-ghost-w {
|
||||
border: 1px solid #e5e6eb; background: #fff; color: #4e5969; padding: 9px 22px;
|
||||
border-radius: 8px; font-size: 14px; cursor: pointer; transition: all .2s; font-family: inherit;
|
||||
}
|
||||
.btn-ghost-w:hover { border-color: #ff7a1a; color: #d96a00; }
|
||||
|
||||
.modal-mask {
|
||||
position: fixed; inset: 0; background: rgba(15,23,42,.45); backdrop-filter: blur(3px);
|
||||
display: none; align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
.modal-mask.show { display: flex; }
|
||||
.modal {
|
||||
width: 530px; max-height: 84vh; overflow-y: auto; background: #fff; border-radius: 16px;
|
||||
padding: 24px; animation: pop .25s ease;
|
||||
}
|
||||
@keyframes pop { from { opacity: 0; transform: scale(.95) translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
.modal h3 { font-size: 17px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||
.modal .sub { font-size: 12px; color: #86909c; margin-top: 4px; }
|
||||
.learn-hint {
|
||||
margin-top: 14px; background: linear-gradient(135deg, rgba(255,178,0,.1), rgba(255,122,26,.08));
|
||||
border: 1px solid #ffe3c2; border-radius: 10px; padding: 12px; font-size: 12px; line-height: 1.8; color: #4e5969;
|
||||
}
|
||||
.learn-hint b { color: #d96a00; }
|
||||
.stat-row { display: flex; gap: 10px; margin-top: 14px; }
|
||||
.stat-box { flex: 1; background: #f7f8fa; border-radius: 10px; padding: 10px; text-align: center; }
|
||||
.stat-box .v { font-size: 18px; font-weight: 700; color: #d96a00; }
|
||||
.stat-box .v small { font-size: 12px; font-weight: 400; color: #86909c; }
|
||||
.stat-box .k { font-size: 11px; color: #86909c; margin-top: 2px; }
|
||||
.level-wrap { margin-top: 14px; }
|
||||
.level-bar { height: 6px; background: #f2f3f5; border-radius: 3px; overflow: hidden; margin-top: 6px; }
|
||||
.level-bar i { display: block; height: 100%; background: linear-gradient(90deg, #ffa200, #ff6600); border-radius: 3px; transition: width .6s; }
|
||||
.level-text { font-size: 12px; color: #4e5969; display: flex; justify-content: space-between; }
|
||||
.conf-row { display: flex; gap: 12px; margin-top: 16px; }
|
||||
.step-list { margin-top: 18px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.step-item {
|
||||
display: flex; align-items: center; gap: 10px; font-size: 13px; color: #86909c;
|
||||
padding: 10px 12px; border: 1px solid #f2f3f5; border-radius: 10px; background: #fafbfc; transition: all .3s;
|
||||
}
|
||||
.step-item .s-icon {
|
||||
width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center;
|
||||
justify-content: center; font-size: 12px; background: #f2f3f5; color: #86909c; flex-shrink: 0;
|
||||
}
|
||||
.step-item.doing { border-color: #ffd9ae; background: rgba(255,178,0,.06); color: #1f2329; }
|
||||
.step-item.doing .s-icon { background: linear-gradient(135deg, #ffa200, #ff6600); color: #fff; animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.step-item.done { color: #1f2329; }
|
||||
.step-item.done .s-icon { background: #22c55e; color: #fff; }
|
||||
.step-item .s-note { margin-left: auto; font-size: 11px; color: #c9cdd4; }
|
||||
.step-item.done .s-note { color: #15803d; }
|
||||
.modal-foot { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; }
|
||||
.done-view { text-align: center; padding: 8px 0; }
|
||||
.done-view .big { font-size: 42px; }
|
||||
.done-view h4 { font-size: 16px; margin: 8px 0 4px; }
|
||||
.done-view .dsub { font-size: 12px; color: #86909c; }
|
||||
.oc-wide {
|
||||
width: 100%; border: none; background: linear-gradient(135deg, #ffa200, #ff6600); color: #fff;
|
||||
border-radius: 10px; padding: 10px; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
margin-bottom: 10px; box-shadow: 0 3px 10px rgba(255,122,26,.25); transition: all .2s; font-family: inherit;
|
||||
}
|
||||
.oc-wide:hover { transform: translateY(-1px); box-shadow: 0 5px 14px rgba(255,122,26,.35); }
|
||||
Generated
+1698
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "ai-assistant-travel",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"description": "旅行商品发布 AI 助手 Demo —— B端商家商品发布平台的 Copilot / Agent 交互原型(Vite + React)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/**
|
||||
* 工程结构说明:
|
||||
* - 仓库根目录的 index.html 是 v1 单文件演示快照,原样保留(不入 Vite 构建)。
|
||||
* - Vite + React 应用的根目录为 app/,入口为 app/index.html,
|
||||
* 这样两者互不干扰,可对照演示「单文件原型 → 前端工程化」的演进过程。
|
||||
*/
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL('./app', import.meta.url)),
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
open: false
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user