mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-22 08:01:34 +08:00
601 lines
19 KiB
Vue
601 lines
19 KiB
Vue
<script setup>
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||
import { refreshJobs, useJobs, api } from '../../composables/useJobs'
|
||
import {
|
||
generationInFlightKey,
|
||
withGenerationInFlight,
|
||
} from '../../composables/useGenerationInFlight'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const { jobs } = useJobs()
|
||
|
||
const selectedId = ref('')
|
||
const businessNotes = ref('')
|
||
const err = ref('')
|
||
const genInFlight = generationInFlightKey()
|
||
const STRATEGY_PREFIX = 'strategy-draft:'
|
||
const strategyDraftPendingJobId = computed(() => {
|
||
for (const k of genInFlight.value) {
|
||
if (k.startsWith(STRATEGY_PREFIX)) return k.slice(STRATEGY_PREFIX.length)
|
||
}
|
||
return null
|
||
})
|
||
const strategyGeneratingAny = computed(() => strategyDraftPendingJobId.value != null)
|
||
const strategyGeneratingThisTask = computed(
|
||
() =>
|
||
strategyDraftPendingJobId.value != null &&
|
||
strategyDraftPendingJobId.value === selectedId.value,
|
||
)
|
||
const strategyGeneratingOtherTask = computed(
|
||
() =>
|
||
strategyDraftPendingJobId.value != null &&
|
||
strategyDraftPendingJobId.value !== selectedId.value,
|
||
)
|
||
/** 勾选则本次仅规则稿(不调用大模型);默认不勾选即走大模型 */
|
||
const rulesOnlyThisRun = ref(false)
|
||
|
||
/** 与竞品矩阵细类一致;空字符串表示不收窄(全关键词样本) */
|
||
const strategyMatrixScope = ref('')
|
||
const matrixGroups = ref([])
|
||
const briefMatrixLoading = ref(false)
|
||
const briefMatrixErr = ref('')
|
||
|
||
const decisions = reactive({
|
||
product_role: '',
|
||
time_horizon: '',
|
||
success_criteria: '',
|
||
non_goals: '',
|
||
battlefield_one_line: '',
|
||
positioning_choice: '',
|
||
competitive_stance: '',
|
||
pillar_product: '',
|
||
pillar_price: '',
|
||
pillar_channel: '',
|
||
pillar_comm: '',
|
||
audience_segment: '',
|
||
competitor_reference: '',
|
||
resource_notes: '',
|
||
marketing_strategy: '',
|
||
general_strategy: '',
|
||
ack_risk_keywords: false,
|
||
ack_risk_price: false,
|
||
ack_risk_concentration: false,
|
||
})
|
||
|
||
const successJobs = computed(() =>
|
||
[...jobs.value].filter((j) => j.status === 'success').sort((a, b) => b.id - a.id),
|
||
)
|
||
|
||
const positioningOptions = [
|
||
{ value: '', label: '暂不勾选(文稿中均为空选)' },
|
||
{ value: 'top', label: '贴顶' },
|
||
{ value: 'mid', label: '卡腰' },
|
||
{ value: 'entry', label: '下探' },
|
||
{ value: 'different', label: '另起带' },
|
||
]
|
||
|
||
/** 对应后端 competitive_stance / 成稿 §5.3:与头部或主竞品「怎么打」,非价位阵地、亦非泛指的「进市场」。 */
|
||
const stanceOptions = [
|
||
{ value: '', label: '暂不填写' },
|
||
{ value: 'flank', label: '侧翼切入(避开头部主战场)' },
|
||
{ value: 'head_on', label: '正面替代(对标头部主战场)' },
|
||
{ value: 'both', label: '分层推进(侧翼 + 正面并行)' },
|
||
{ value: 'undecided', label: '尚未拍板' },
|
||
]
|
||
|
||
function buildPayload() {
|
||
const generator = rulesOnlyThisRun.value ? 'rules' : 'llm'
|
||
return {
|
||
generator,
|
||
business_notes: businessNotes.value,
|
||
product_role: decisions.product_role,
|
||
time_horizon: decisions.time_horizon,
|
||
success_criteria: decisions.success_criteria,
|
||
non_goals: decisions.non_goals,
|
||
battlefield_one_line: decisions.battlefield_one_line,
|
||
positioning_choice: decisions.positioning_choice,
|
||
competitive_stance: decisions.competitive_stance,
|
||
pillar_product: decisions.pillar_product,
|
||
pillar_price: decisions.pillar_price,
|
||
pillar_channel: decisions.pillar_channel,
|
||
pillar_comm: decisions.pillar_comm,
|
||
audience_segment: decisions.audience_segment,
|
||
competitor_reference: decisions.competitor_reference,
|
||
resource_notes: decisions.resource_notes,
|
||
marketing_strategy: decisions.marketing_strategy,
|
||
general_strategy: decisions.general_strategy,
|
||
ack_risk_keywords: decisions.ack_risk_keywords,
|
||
ack_risk_price: decisions.ack_risk_price,
|
||
ack_risk_concentration: decisions.ack_risk_concentration,
|
||
...(strategyMatrixScope.value
|
||
? { strategy_matrix_group: strategyMatrixScope.value }
|
||
: {}),
|
||
}
|
||
}
|
||
|
||
const STORAGE_KEY = (id) => `ma_strategy_draft_${id}`
|
||
|
||
function formatJobOption(j) {
|
||
const t = j.created_at
|
||
const tail = t ? String(t).replace('T', ' ').slice(0, 16) : ''
|
||
return tail ? `#${j.id} · ${j.keyword} · ${tail}` : `#${j.id} · ${j.keyword}`
|
||
}
|
||
|
||
async function loadList() {
|
||
try {
|
||
await refreshJobs()
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
async function loadMatrixGroupsForJob(id) {
|
||
matrixGroups.value = []
|
||
strategyMatrixScope.value = ''
|
||
briefMatrixErr.value = ''
|
||
if (!id) return
|
||
briefMatrixLoading.value = true
|
||
try {
|
||
const r = await api(`/api/jobs/${id}/competitor-brief/`)
|
||
const text = await r.text()
|
||
if (!r.ok) {
|
||
try {
|
||
briefMatrixErr.value = JSON.parse(text).detail || text
|
||
} catch {
|
||
briefMatrixErr.value = text || `HTTP ${r.status}`
|
||
}
|
||
return
|
||
}
|
||
const data = JSON.parse(text)
|
||
const mg = data.matrix_groups
|
||
matrixGroups.value = Array.isArray(mg) ? mg : []
|
||
const saved = sessionStorage.getItem(`ma_strategy_scope_${id}`)
|
||
if (saved && matrixGroups.value.some((g) => g.group === saved)) {
|
||
strategyMatrixScope.value = saved
|
||
}
|
||
} catch (e) {
|
||
briefMatrixErr.value = String(e)
|
||
} finally {
|
||
briefMatrixLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function generateAndGoPreview() {
|
||
const id = selectedId.value
|
||
if (!id) return
|
||
err.value = ''
|
||
const key = `${STRATEGY_PREFIX}${id}`
|
||
await withGenerationInFlight(key, async () => {
|
||
try {
|
||
const r = await api(`/api/jobs/${id}/strategy-draft/`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(buildPayload()),
|
||
})
|
||
const text = await r.text()
|
||
if (!r.ok) {
|
||
try {
|
||
const j = JSON.parse(text)
|
||
err.value = j.detail || text
|
||
} catch {
|
||
err.value = text || `HTTP ${r.status}`
|
||
}
|
||
return
|
||
}
|
||
const j = JSON.parse(text)
|
||
sessionStorage.setItem(
|
||
STORAGE_KEY(id),
|
||
JSON.stringify({
|
||
markdown: j.markdown || '',
|
||
keyword: j.keyword || '',
|
||
generated_at: j.generated_at || '',
|
||
}),
|
||
)
|
||
router.push({ path: '/jd/strategy-view', query: { job: id } })
|
||
} catch (e) {
|
||
err.value = String(e)
|
||
}
|
||
})
|
||
}
|
||
|
||
onMounted(loadList)
|
||
|
||
watch(selectedId, (id) => {
|
||
loadMatrixGroupsForJob(id)
|
||
})
|
||
|
||
watch(strategyMatrixScope, (v) => {
|
||
const jid = selectedId.value
|
||
if (!jid) return
|
||
if (v) sessionStorage.setItem(`ma_strategy_scope_${jid}`, v)
|
||
else sessionStorage.removeItem(`ma_strategy_scope_${jid}`)
|
||
})
|
||
|
||
watch(
|
||
() => route.query.job,
|
||
(j) => {
|
||
if (j) selectedId.value = String(j)
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
watch(
|
||
successJobs,
|
||
(list) => {
|
||
if (selectedId.value) return
|
||
if (route.query.job) return
|
||
if (list.length) selectedId.value = String(list[0].id)
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<section class="ma-card">
|
||
<h2>策略生成</h2>
|
||
<p class="hint-top">
|
||
选择<strong>已成功</strong>任务,在下方选择<strong>策略类目</strong>
|
||
<RouterLink to="/jd/strategy-view">策略稿预览</RouterLink>
|
||
。<strong>已填项</strong>写入底稿并由大模型落实;<strong>未填项</strong>可由大模型结合监测数据推断。
|
||
</p>
|
||
|
||
|
||
<div class="toolbar">
|
||
<label class="chk-inline">
|
||
<input v-model="rulesOnlyThisRun" type="checkbox" />
|
||
本次仅生成规则稿(不做大模型全文润色,更快、不调用智能服务)
|
||
</label>
|
||
</div>
|
||
<div class="toolbar">
|
||
<label class="sel-label">任务</label>
|
||
<select v-model="selectedId" class="job-select">
|
||
<option value="" disabled>请选择任务</option>
|
||
<option v-for="j in successJobs" :key="j.id" :value="String(j.id)">
|
||
{{ formatJobOption(j) }}
|
||
</option>
|
||
</select>
|
||
<button
|
||
type="button"
|
||
class="ma-btn ma-btn-primary"
|
||
:disabled="!selectedId || strategyGeneratingAny || briefMatrixLoading"
|
||
@click="generateAndGoPreview"
|
||
>
|
||
{{ strategyGeneratingThisTask ? '生成中…' : '生成并前往预览' }}
|
||
</button>
|
||
</div>
|
||
<div v-if="selectedId" class="toolbar toolbar-stack">
|
||
<label class="sel-label">主推类目(矩阵细类)</label>
|
||
<select
|
||
v-model="strategyMatrixScope"
|
||
class="job-select"
|
||
:disabled="briefMatrixLoading || strategyGeneratingAny"
|
||
>
|
||
<option value="">全部分类(不收窄 · 与全关键词监测样本一致)</option>
|
||
<option v-for="g in matrixGroups" :key="g.index" :value="g.group">
|
||
{{ g.group }}({{ g.sku_count }} 款)
|
||
</option>
|
||
</select>
|
||
<span v-if="briefMatrixLoading" class="ma-muted">正在加载矩阵分组…</span>
|
||
<span v-else class="ma-muted ma-hint-sub"
|
||
>与成稿「策略范围与前提」中的主推细类及报告第五章矩阵一致;收窄后监测摘要与报告节选仅针对该细类。</span
|
||
>
|
||
</div>
|
||
<p v-if="briefMatrixErr" class="ma-err">{{ briefMatrixErr }}</p>
|
||
<p v-if="strategyGeneratingOtherTask" class="ma-warn-banner">
|
||
任务 #{{ strategyDraftPendingJobId }} 的策略稿正在生成中,请稍候再切换任务或重复提交。
|
||
</p>
|
||
<p v-if="err" class="ma-err">{{ err }}</p>
|
||
<p v-if="!successJobs.length" class="ma-muted">暂无成功任务,请先在「搜索采集」跑通一条流水线。</p>
|
||
|
||
<fieldset class="fieldset">
|
||
<legend>策略范围与前提 · 摘要依据</legend>
|
||
<p class="fieldset-hint">
|
||
对应成稿章节「<strong>策略范围与前提</strong>」与「<strong>一、顾客是谁 · 本品聚焦</strong>」表格;监测词与批次由任务自动带出。
|
||
</p>
|
||
<label class="fld">
|
||
<span>本品角色(策略服务对象)</span>
|
||
<input v-model="decisions.product_role" type="text" placeholder="如:追赶型 / 新品 / 防守 / 拓品类" />
|
||
</label>
|
||
<label class="fld fld-block">
|
||
<span>一句话战场</span>
|
||
<textarea
|
||
v-model="decisions.battlefield_one_line"
|
||
rows="2"
|
||
placeholder="在哪个需求场景、与谁争夺同一批检索与购买用户?"
|
||
/>
|
||
</label>
|
||
<label class="fld fld-block">
|
||
<span>目标客群 / 场景</span>
|
||
<input
|
||
v-model="decisions.audience_segment"
|
||
type="text"
|
||
placeholder="为谁、什么场景(可选)"
|
||
/>
|
||
</label>
|
||
<label class="fld fld-block">
|
||
<span>主要对标</span>
|
||
<input
|
||
v-model="decisions.competitor_reference"
|
||
type="text"
|
||
placeholder="品牌或价位带参照(可选)"
|
||
/>
|
||
</label>
|
||
<label class="fld">
|
||
<span>时间范围</span>
|
||
<input v-model="decisions.time_horizon" type="text" placeholder="如:本季度 / 未来 12 周" />
|
||
</label>
|
||
<label class="fld fld-block">
|
||
<span>成功标准(可量化)</span>
|
||
<textarea v-model="decisions.success_criteria" rows="2" placeholder="如:搜索位次、转化、复购等(对应「本阶段策略目标」)" />
|
||
</label>
|
||
<label class="fld fld-block">
|
||
<span>非目标</span>
|
||
<textarea v-model="decisions.non_goals" rows="2" placeholder="明确本阶段不做什么(可选)" />
|
||
</label>
|
||
<label class="fld fld-block">
|
||
<span>资源与预算备注</span>
|
||
<textarea
|
||
v-model="decisions.resource_notes"
|
||
rows="2"
|
||
placeholder="人力、投放、产能等(可选;亦进入「六、阶段目标与路径」)"
|
||
/>
|
||
</label>
|
||
</fieldset>
|
||
|
||
<fieldset class="fieldset">
|
||
<legend>五、与其它品牌有何不同 — 竞争应对取向</legend>
|
||
<p class="fieldset-hint">
|
||
对应成稿「<strong>§5.3 竞争应对</strong>」:选的是<strong>与头部或主竞品交锋时的主打法</strong>(侧翼 / 正面等)。与下方「价位阵地」(贴顶/卡腰/下探)回答的问题不同,也不是泛指的「是否进新市场」。
|
||
</p>
|
||
<label class="fld fld-block">
|
||
<span>面对竞品时的主打法</span>
|
||
<select v-model="decisions.competitive_stance" class="job-select full">
|
||
<option v-for="o in stanceOptions" :key="o.value || 'empty'" :value="o.value">
|
||
{{ o.label }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
</fieldset>
|
||
|
||
<fieldset class="fieldset">
|
||
<legend>六、阶段目标与路径</legend>
|
||
<p class="fieldset-hint">
|
||
对应「<strong>六、阶段目标与路径</strong>」;「二、针对痛点要怎么做」表格主要由监测与模型归纳,本页不单独列项。
|
||
</p>
|
||
<label class="fld fld-block">
|
||
<span>营销策略</span>
|
||
<textarea
|
||
v-model="decisions.marketing_strategy"
|
||
rows="3"
|
||
placeholder="传播、活动、投放、内容主线(可选)"
|
||
/>
|
||
</label>
|
||
<label class="fld fld-block">
|
||
<span>总体策略</span>
|
||
<textarea
|
||
v-model="decisions.general_strategy"
|
||
rows="3"
|
||
placeholder="增长 / 品类 / 经营总原则(可选)"
|
||
/>
|
||
</label>
|
||
</fieldset>
|
||
|
||
<fieldset class="fieldset">
|
||
<legend>七 · 八、品牌四线与战术支柱(4P)</legend>
|
||
<p class="fieldset-hint">
|
||
对应成稿「<strong>七、品牌四线</strong>」与「<strong>八、战术支柱</strong>」。其中<strong>价位阵地</strong>(贴顶/卡腰/下探/另起带)写入 <strong>§8.2 定价策略</strong>,回答的是「在什么价位带上打」,不是「用户为什么选这个品牌」——后者在第四章由承诺、证据与调性承载(底稿占位,可由大模型补全)。
|
||
</p>
|
||
<label class="fld fld-block">
|
||
<span>产品</span>
|
||
<textarea v-model="decisions.pillar_product" rows="2" placeholder="产品侧动作或差异(可选)" />
|
||
</label>
|
||
<label class="fld fld-block">
|
||
<span>价位阵地(选一条)</span>
|
||
<select v-model="decisions.positioning_choice" class="job-select full">
|
||
<option v-for="o in positioningOptions" :key="o.value || 'empty'" :value="o.value">
|
||
{{ o.label }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label class="fld fld-block">
|
||
<span>定价(战术支柱 · 补充叙述)</span>
|
||
<textarea
|
||
v-model="decisions.pillar_price"
|
||
rows="2"
|
||
placeholder="除价位阵地外,价格与价值呈现、跟价原则等(可选)"
|
||
/>
|
||
</label>
|
||
<label class="fld fld-block">
|
||
<span>渠道与触点</span>
|
||
<textarea v-model="decisions.pillar_channel" rows="2" placeholder="渠道、货架与触点(可选)" />
|
||
</label>
|
||
<label class="fld fld-block">
|
||
<span>传播与内容</span>
|
||
<textarea v-model="decisions.pillar_comm" rows="2" placeholder="传播、内容、沟通(可选)" />
|
||
</label>
|
||
</fieldset>
|
||
|
||
<fieldset class="fieldset">
|
||
<legend>九、风险、假设与待验证(确认知晓)</legend>
|
||
<label class="chk">
|
||
<input v-model="decisions.ack_risk_keywords" type="checkbox" />
|
||
关注词 / 场景可能以偏概全(需原评论抽样)
|
||
</label>
|
||
<label class="chk">
|
||
<input v-model="decisions.ack_risk_price" type="checkbox" />
|
||
价格带可能含大促或异常挂价(需核对清洗与计价规则)
|
||
</label>
|
||
<label class="chk">
|
||
<input v-model="decisions.ack_risk_concentration" type="checkbox" />
|
||
列表集中度与深入样本品牌可能矛盾(需解释差异)
|
||
</label>
|
||
</fieldset>
|
||
|
||
<fieldset class="fieldset">
|
||
<legend>业务约束与备注</legend>
|
||
<p class="fieldset-hint">
|
||
对应成稿「<strong>十、下一步与节奏</strong>」下的<strong>业务约束与备注</strong>;供法务、渠道红线、内部判断等补充。
|
||
</p>
|
||
<label class="fld fld-block">
|
||
<span>业务备注</span>
|
||
<textarea
|
||
v-model="businessNotes"
|
||
rows="4"
|
||
placeholder="渠道红线、价位策略、竞品对标、预算与组织约束等"
|
||
/>
|
||
</label>
|
||
</fieldset>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.hint-top {
|
||
margin: 0 0 1rem;
|
||
font-size: 0.88rem;
|
||
color: #4b5563;
|
||
line-height: 1.55;
|
||
}
|
||
.hint-flow {
|
||
margin-top: -0.6rem;
|
||
padding-top: 0.5rem;
|
||
border-top: 1px solid #e5e7eb;
|
||
font-size: 0.84rem;
|
||
}
|
||
.hint-top a,
|
||
.hint-top :deep(a) {
|
||
color: #2563eb;
|
||
font-weight: 500;
|
||
}
|
||
.toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.toolbar-stack {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
.toolbar-stack .sel-label {
|
||
margin-bottom: -0.25rem;
|
||
}
|
||
.sel-label {
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
color: #374151;
|
||
}
|
||
.job-select {
|
||
flex: 1;
|
||
min-width: 220px;
|
||
padding: 0.5rem 0.65rem;
|
||
border-radius: 6px;
|
||
border: 1px solid #d1d5db;
|
||
font: inherit;
|
||
}
|
||
.job-select.full {
|
||
width: 100%;
|
||
min-width: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
.ma-muted {
|
||
color: #64748b;
|
||
}
|
||
.ma-warn-banner {
|
||
margin: 0.5rem 0 0;
|
||
padding: 0.5rem 0.75rem;
|
||
font-size: 0.86rem;
|
||
line-height: 1.45;
|
||
color: #92400e;
|
||
background: #fffbeb;
|
||
border: 1px solid #fcd34d;
|
||
border-radius: 6px;
|
||
}
|
||
.fieldset {
|
||
margin: 1.25rem 0 0;
|
||
padding: 0.85rem 1rem 1rem;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
background: #fafafa;
|
||
}
|
||
.fieldset legend {
|
||
padding: 0 0.35rem;
|
||
font-size: 0.88rem;
|
||
font-weight: 600;
|
||
color: #1f2937;
|
||
}
|
||
.fieldset-hint {
|
||
margin: 0 0 0.5rem;
|
||
font-size: 0.8rem;
|
||
line-height: 1.5;
|
||
color: #6b7280;
|
||
}
|
||
.ma-hint-sub {
|
||
display: block;
|
||
margin-top: 0.35rem;
|
||
font-size: 0.8rem;
|
||
line-height: 1.45;
|
||
}
|
||
.fld {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.35rem;
|
||
margin-top: 0.65rem;
|
||
}
|
||
.fld:first-of-type {
|
||
margin-top: 0.35rem;
|
||
}
|
||
.fld-block {
|
||
width: 100%;
|
||
}
|
||
.fld span {
|
||
font-size: 0.82rem;
|
||
font-weight: 500;
|
||
color: #4b5563;
|
||
}
|
||
.fld input[type='text'],
|
||
.fld textarea {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
padding: 0.5rem 0.65rem;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
font: inherit;
|
||
font-size: 0.88rem;
|
||
}
|
||
.fld textarea {
|
||
resize: vertical;
|
||
min-height: 52px;
|
||
}
|
||
.chk {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 0.5rem;
|
||
margin-top: 0.5rem;
|
||
font-size: 0.86rem;
|
||
color: #374151;
|
||
line-height: 1.45;
|
||
cursor: pointer;
|
||
}
|
||
.chk input {
|
||
margin-top: 0.2rem;
|
||
}
|
||
.chk-inline {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 0.5rem;
|
||
font-size: 0.86rem;
|
||
color: #374151;
|
||
line-height: 1.45;
|
||
cursor: pointer;
|
||
width: 100%;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
.chk-inline input {
|
||
margin-top: 0.2rem;
|
||
}
|
||
</style>
|