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:
2026-09-16 13:37:58 +08:00
parent 609e0afb29
commit 01dcd83987
29 changed files with 3872 additions and 19 deletions
+3
View File
@@ -14,5 +14,8 @@
# 本地服务
node_modules/
# 构建产物
dist/
# 本地推送脚本(含本地环境信息,不入库)
push-to-gitea.sh
+53 -19
View File
@@ -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`。
+12
View File
@@ -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>
+22
View File
@@ -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>
)
}
+97
View File
@@ -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>
)
}
+35
View File
@@ -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>
)
}
+63
View File
@@ -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>
)
}
+20
View File
@@ -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>
)
}
+118
View File
@@ -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>
)
}
+33
View File
@@ -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>
)
}
+21
View File
@@ -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>
)
}
+16
View File
@@ -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>
)
}
+16
View File
@@ -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>
)
}
+37
View File
@@ -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>
)
}
+34
View File
@@ -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>
)
}
+26
View File
@@ -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>
)
}
+56
View File
@@ -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>
)
}
+65
View File
@@ -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>
)
}
+245
View File
@@ -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)
}
}
+60
View File
@@ -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
}
+36
View File
@@ -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% 定价策略',
'首月立减冲榜促销习惯'
]
}
+10
View File
@@ -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>
)
+10
View File
@@ -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
}
+541
View File
@@ -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, '&lt;')
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
}
}
+463
View File
@@ -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); }
+1698
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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"
}
}
+22
View File
@@ -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
}
})