feat(ai): 写作模式加「写作参数」面板,可选填活动信息
选了文体(走「内容创作与宣传助手」)后,输入框上方出现可折叠的「写作参数」: 活动类型 chip(含「不限」= 不下发 event_type)、活动主题/时间/地点/参与人员四格, 字数并入同一块。自由创作(走「AI 创意助手」)时整块不出现。 - 只有实际填了参数才下发,空格子不带;活动类型默认空串,不替用户猜类型。 - 只填参数、不写正文也能发送,气泡上用一句「(按写作参数生成:…)」占位, 避免出现空消息。 - 面板默认收起。
This commit is contained in:
@@ -241,6 +241,99 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<!--
|
||||||
|
写作参数:只有选了文体(= 走「内容创作与宣传助手」)才有意义。
|
||||||
|
自由创作时「AI 创意助手」只吃 user_content,这块整块隐藏。
|
||||||
|
默认收起:写作模式下多数人只写一段话,参数是补充项。
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
v-if="activeChatMode === 'writing' && writingTemplateMode"
|
||||||
|
class="param-block"
|
||||||
|
:class="{ 'is-open': writingParamsOpen }"
|
||||||
|
>
|
||||||
|
<div class="param-head">
|
||||||
|
<span class="param-title">写作参数</span>
|
||||||
|
<span class="param-hint">活动信息可选填,填了会一并提交</span>
|
||||||
|
<span v-if="writingParamCount" class="param-count">
|
||||||
|
已填 {{ writingParamCount }} 项
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="param-toggle"
|
||||||
|
type="button"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="writingParamsOpen = !writingParamsOpen"
|
||||||
|
>
|
||||||
|
{{ writingParamsOpen ? "收起" : "展开" }}
|
||||||
|
<span class="param-caret" :class="{ 'is-open': writingParamsOpen }">⌄</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="writingParamsOpen" class="param-body">
|
||||||
|
<div class="param-row">
|
||||||
|
<span class="param-field-label">活动类型</span>
|
||||||
|
<button
|
||||||
|
class="ratio-option"
|
||||||
|
type="button"
|
||||||
|
:class="{ 'is-active': !writingParams.eventType }"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="writingParams.eventType = ''"
|
||||||
|
>
|
||||||
|
不限
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="item in WRITING_EVENT_TYPE_OPTIONS"
|
||||||
|
:key="item"
|
||||||
|
class="ratio-option"
|
||||||
|
type="button"
|
||||||
|
:class="{ 'is-active': writingParams.eventType === item }"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="writingParams.eventType = item"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="param-grid">
|
||||||
|
<label class="param-field">
|
||||||
|
<span>活动主题</span>
|
||||||
|
<input
|
||||||
|
v-model="writingParams.theme"
|
||||||
|
type="text"
|
||||||
|
:disabled="loading"
|
||||||
|
placeholder="例如:春季植树志愿服务"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="param-field">
|
||||||
|
<span>活动时间</span>
|
||||||
|
<input
|
||||||
|
v-model="writingParams.time"
|
||||||
|
type="text"
|
||||||
|
:disabled="loading"
|
||||||
|
placeholder="例如:2026年4月8日下午"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="param-field">
|
||||||
|
<span>活动地点</span>
|
||||||
|
<input
|
||||||
|
v-model="writingParams.location"
|
||||||
|
type="text"
|
||||||
|
:disabled="loading"
|
||||||
|
placeholder="例如:校史馆广场"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="param-field">
|
||||||
|
<span>参与人员</span>
|
||||||
|
<input
|
||||||
|
v-model="writingParams.participants"
|
||||||
|
type="text"
|
||||||
|
:disabled="loading"
|
||||||
|
placeholder="例如:青年志愿者代表共120人"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="param-foot">
|
||||||
|
下方输入框里的内容会作为「亮点内容」提交;活动主题留空时由上游自己拟标题。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="activeChatMode === 'image'" class="ratio-panel attach-panel">
|
<div v-if="activeChatMode === 'image'" class="ratio-panel attach-panel">
|
||||||
<span class="ratio-label">参考素材</span>
|
<span class="ratio-label">参考素材</span>
|
||||||
<button
|
<button
|
||||||
@@ -320,7 +413,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import "bytemd/dist/index.min.css";
|
import "bytemd/dist/index.min.css";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import "github-markdown-css/github-markdown-light.css";
|
import "github-markdown-css/github-markdown-light.css";
|
||||||
import gfm from "@bytemd/plugin-gfm";
|
import gfm from "@bytemd/plugin-gfm";
|
||||||
@@ -338,6 +431,7 @@ import { manuscriptGen } from "@/api/ai/manuscriptGen";
|
|||||||
import {
|
import {
|
||||||
WRITING_DOC_TYPE_FREE,
|
WRITING_DOC_TYPE_FREE,
|
||||||
WRITING_DOC_TYPE_OPTIONS,
|
WRITING_DOC_TYPE_OPTIONS,
|
||||||
|
WRITING_EVENT_TYPE_OPTIONS,
|
||||||
WRITING_WORD_COUNT_OPTIONS
|
WRITING_WORD_COUNT_OPTIONS
|
||||||
} from "./writing-params";
|
} from "./writing-params";
|
||||||
import { creativeAssistant, uploadAiFile } from "@/api/ai/creativeAssistant";
|
import { creativeAssistant, uploadAiFile } from "@/api/ai/creativeAssistant";
|
||||||
@@ -405,6 +499,17 @@ const fileInputRef = ref<HTMLInputElement>();
|
|||||||
// 写作模式的显式选择:默认 auto = 不下发这两个字段的值,交给上游 Dify 识别
|
// 写作模式的显式选择:默认 auto = 不下发这两个字段的值,交给上游 Dify 识别
|
||||||
const writingDocType = ref(WRITING_DOC_TYPE_FREE);
|
const writingDocType = ref(WRITING_DOC_TYPE_FREE);
|
||||||
const writingWordCount = ref("auto");
|
const writingWordCount = ref("auto");
|
||||||
|
// 「写作参数」面板:只有选了文体(走「内容创作与宣传助手」)时才出现。
|
||||||
|
// 这些字段都是上游开始节点的可选变量,留空就不下发(后端 buildManuscriptInputs 会跳过空串)。
|
||||||
|
const writingParamsOpen = ref(false);
|
||||||
|
const writingParams = reactive({
|
||||||
|
// 空串 = 不限,不替用户猜活动类型;选了才下发 event_type
|
||||||
|
eventType: "",
|
||||||
|
theme: "",
|
||||||
|
time: "",
|
||||||
|
location: "",
|
||||||
|
participants: ""
|
||||||
|
});
|
||||||
const wsConnected = ref(false);
|
const wsConnected = ref(false);
|
||||||
const deletingChatId = ref<number>();
|
const deletingChatId = ref<number>();
|
||||||
const messages = ref<ChatMessageItem[]>([]);
|
const messages = ref<ChatMessageItem[]>([]);
|
||||||
@@ -433,9 +538,20 @@ const userName = computed(
|
|||||||
*/
|
*/
|
||||||
const showAdminEntry = computed(() => !isOnlyStudent());
|
const showAdminEntry = computed(() => !isOnlyStudent());
|
||||||
|
|
||||||
const canSend = computed(
|
const canSend = computed(() => {
|
||||||
() => content.value.trim().length > 0 && !loading.value && !uploadingFiles.value
|
if (loading.value || uploadingFiles.value) {
|
||||||
);
|
return false;
|
||||||
|
}
|
||||||
|
if (content.value.trim().length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 选了文体并填了写作参数时,正文可以先空着:亮点由参数与上游提示词补
|
||||||
|
return (
|
||||||
|
activeChatMode.value === "writing" &&
|
||||||
|
writingTemplateMode.value &&
|
||||||
|
writingParamCount.value > 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const isCanceledError = (error: any) =>
|
const isCanceledError = (error: any) =>
|
||||||
error?.code === "ERR_CANCELED" ||
|
error?.code === "ERR_CANCELED" ||
|
||||||
@@ -463,8 +579,22 @@ const writingTemplateMode = computed(
|
|||||||
() => writingDocType.value !== WRITING_DOC_TYPE_FREE
|
() => writingDocType.value !== WRITING_DOC_TYPE_FREE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写作参数里用户真正填了几项(不含活动类型:它默认「不限」,算进去会让面板
|
||||||
|
* 一打开就显示「已填 1 项」,反而误导)。
|
||||||
|
*/
|
||||||
|
const writingParamCount = computed(
|
||||||
|
() =>
|
||||||
|
[writingParams.theme, writingParams.time, writingParams.location, writingParams.participants].filter(
|
||||||
|
(value) => String(value || "").trim().length > 0
|
||||||
|
).length
|
||||||
|
);
|
||||||
|
|
||||||
const inputPlaceholder = computed(() => {
|
const inputPlaceholder = computed(() => {
|
||||||
if (activeChatMode.value === "writing") {
|
if (activeChatMode.value === "writing") {
|
||||||
|
if (writingTemplateMode.value) {
|
||||||
|
return "写清活动亮点、流程、成果等关键信息,会作为「亮点内容」提交;标题、地点等可在上方「写作参数」里补";
|
||||||
|
}
|
||||||
return "描述你想创作的内容,例如:帮我写一段迎新晚会的主持人开场词,口语化一点(选了文体后才需要再定字数)";
|
return "描述你想创作的内容,例如:帮我写一段迎新晚会的主持人开场词,口语化一点(选了文体后才需要再定字数)";
|
||||||
}
|
}
|
||||||
if (activeChatMode.value === "image") {
|
if (activeChatMode.value === "image") {
|
||||||
@@ -1015,9 +1145,9 @@ const pickString = (...values: unknown[]) => {
|
|||||||
const buildWritingPayload = (query: string) => {
|
const buildWritingPayload = (query: string) => {
|
||||||
// 两条路,都不解析自然语言(2026-10-21 改造):
|
// 两条路,都不解析自然语言(2026-10-21 改造):
|
||||||
// - 自由创作:原话整段作为 userContent,后端见 docType 为空就转「AI 创意助手」;
|
// - 自由创作:原话整段作为 userContent,后端见 docType 为空就转「AI 创意助手」;
|
||||||
// - 选了文体:原话整段作为 highlights,只带界面显式选过的 docType / number,
|
// - 选了文体:原话整段作为 highlights,「写作参数」里填了的活动类型 / 主题 / 时间 /
|
||||||
// 其余可选字段留空交给上游。doc_type / Number 是上游提示词的**主控指令**,
|
// 地点 / 参与人员原样下发,没填的不带。doc_type / Number 是上游提示词的
|
||||||
// 本地猜会猜错(见 issue 13)。
|
// **主控指令**,本地猜会猜错(见 issue 13)。
|
||||||
const content = String(query || "").trim();
|
const content = String(query || "").trim();
|
||||||
if (writingDocType.value === WRITING_DOC_TYPE_FREE) {
|
if (writingDocType.value === WRITING_DOC_TYPE_FREE) {
|
||||||
return { docType: "", userContent: content };
|
return { docType: "", userContent: content };
|
||||||
@@ -1027,12 +1157,34 @@ const buildWritingPayload = (query: string) => {
|
|||||||
highlights: content,
|
highlights: content,
|
||||||
files: []
|
files: []
|
||||||
};
|
};
|
||||||
|
const optionalFields: [string, string][] = [
|
||||||
|
["eventType", writingParams.eventType],
|
||||||
|
["theme", writingParams.theme],
|
||||||
|
["time", writingParams.time],
|
||||||
|
["location", writingParams.location],
|
||||||
|
["participants", writingParams.participants]
|
||||||
|
];
|
||||||
|
optionalFields.forEach(([key, value]) => {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (text) {
|
||||||
|
payload[key] = text;
|
||||||
|
}
|
||||||
|
});
|
||||||
if (writingWordCount.value !== "auto") {
|
if (writingWordCount.value !== "auto") {
|
||||||
payload.number = Number(writingWordCount.value);
|
payload.number = Number(writingWordCount.value);
|
||||||
}
|
}
|
||||||
return payload;
|
return payload;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 没写正文、只填了写作参数时,聊天气泡上给一句说明,不显示空消息 */
|
||||||
|
const writingRequestSummary = () => {
|
||||||
|
const parts = [
|
||||||
|
writingDocType.value,
|
||||||
|
writingParams.theme || writingParams.location || writingParams.time
|
||||||
|
].filter((item) => String(item || "").trim().length > 0);
|
||||||
|
return `(按写作参数生成:${parts.join(" · ")})`;
|
||||||
|
};
|
||||||
|
|
||||||
const formatWritingResult = (payload: unknown) => {
|
const formatWritingResult = (payload: unknown) => {
|
||||||
if (typeof payload === "string") {
|
if (typeof payload === "string") {
|
||||||
return payload;
|
return payload;
|
||||||
@@ -1439,6 +1591,12 @@ const onSend = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const query = content.value.trim();
|
const query = content.value.trim();
|
||||||
|
// 只填了写作参数、没写正文时,气泡上仍要有一句话,否则是一条空消息
|
||||||
|
const displayQuery =
|
||||||
|
query ||
|
||||||
|
(activeChatMode.value === "writing" && writingTemplateMode.value
|
||||||
|
? writingRequestSummary()
|
||||||
|
: query);
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
stopping.value = false;
|
stopping.value = false;
|
||||||
currentTaskId.value = undefined;
|
currentTaskId.value = undefined;
|
||||||
@@ -1449,7 +1607,7 @@ const onSend = async () => {
|
|||||||
messages.value.push({
|
messages.value.push({
|
||||||
id: createMessageId("user"),
|
id: createMessageId("user"),
|
||||||
role: "user",
|
role: "user",
|
||||||
content: query
|
content: displayQuery
|
||||||
});
|
});
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
id: assistantId,
|
id: assistantId,
|
||||||
@@ -2118,6 +2276,163 @@ export default {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 「写作参数」面板:只在选了文体时出现,默认收起,避免挤占写作输入区 */
|
||||||
|
.param-block {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px dashed rgba(21, 144, 255, 0.28);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f9fcff;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-block.is-open {
|
||||||
|
border-style: solid;
|
||||||
|
background: #f5fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-title {
|
||||||
|
color: #1590ff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-hint {
|
||||||
|
color: #8aa4bd;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-count {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(21, 144, 255, 0.12);
|
||||||
|
color: #1590ff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: #1590ff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-toggle:hover:not(:disabled) {
|
||||||
|
background: rgba(21, 144, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-toggle:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-caret {
|
||||||
|
display: inline-block;
|
||||||
|
/* 收起时箭头朝下(提示「还有内容在下面」),展开后翻上来 */
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-caret.is-open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-body {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid rgba(21, 144, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-field-label {
|
||||||
|
color: #5f7f9f;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-field span {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #5f7f9f;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-field input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid rgba(21, 144, 255, 0.24);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #33475b;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-field input::placeholder {
|
||||||
|
color: #b6c9da;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-field input:focus {
|
||||||
|
border-color: #1590ff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(21, 144, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-field input:disabled {
|
||||||
|
background: #f4f7fa;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-foot {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #8aa4bd;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.param-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.attach-hint {
|
.attach-hint {
|
||||||
color: #8aa4bd;
|
color: #8aa4bd;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -42,3 +42,19 @@ export const WRITING_WORD_COUNT_OPTIONS = [
|
|||||||
{ label: '1000字', value: '1000' },
|
{ label: '1000字', value: '1000' },
|
||||||
{ label: '1500字', value: '1500' }
|
{ label: '1500字', value: '1500' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 活动类型(上游 `event_type`)的候选值。
|
||||||
|
*
|
||||||
|
* 上游开始节点把 event_type 声明成普通 text-input,没有枚举约束,这里给一组常用值
|
||||||
|
* 只是为了让用户不用手打;与两个「写作表单页」的取值保持一致
|
||||||
|
* (tuanwei-uniapp/src/pages/manuscript 与 tuanwei-admin/src/views/gxmu/manuscriptGen)。
|
||||||
|
*/
|
||||||
|
export const WRITING_EVENT_TYPE_OPTIONS = [
|
||||||
|
'校园活动',
|
||||||
|
'志愿服务',
|
||||||
|
'会议论坛',
|
||||||
|
'社会实践',
|
||||||
|
'文体活动',
|
||||||
|
'品牌项目'
|
||||||
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user