| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334 |
- <template>
- <a-drawer
- :open="open"
- title="分析模板库"
- wrap-class-name="codex-template"
- :width="520"
- @close="emit('update:open', false)"
- >
- <div class="codex-template__bar">
- <a-input v-model:value="keyword" size="small" allow-clear placeholder="搜索模板:标题 / 内容 / 分组" />
- <a-button size="small" :disabled="!draft.trim() || saving" @click="startSave">存为模板</a-button>
- <a-button size="small" @click="onExport">导出</a-button>
- <a-button size="small" @click="onImportClick">导入</a-button>
- <input
- ref="fileInputRef"
- class="codex-template__file"
- type="file"
- accept="application/json,.json"
- @change="onFileChange"
- />
- </div>
- <!-- 命名用行内表单:Electron 渲染进程不支持 window.prompt -->
- <div v-if="saving" class="codex-template__save-row">
- <a-input
- ref="titleInputRef"
- v-model:value="savingTitle"
- size="small"
- placeholder="模板名称"
- @keydown.enter="confirmSave"
- />
- <a-button size="small" type="primary" :disabled="!savingTitle.trim()" @click="confirmSave">保存</a-button>
- <a-button size="small" @click="cancelSave">取消</a-button>
- </div>
- <p v-else-if="!draft.trim()" class="codex-template__hint">
- 在输入框里写好提问后,回到这里可以把它存成模板,下次一键复用。
- </p>
- <div v-if="!groups.length" class="codex-placeholder">没有匹配的模板。</div>
- <section v-for="group in groups" :key="group.group" class="codex-template__group">
- <div class="codex-template__group-head">
- <span>{{ group.group }}</span>
- <span class="codex-template__group-count">{{ group.items.length }}</span>
- </div>
- <article v-for="item in group.items" :key="item.id" class="codex-template__item">
- <div class="codex-template__item-head">
- <span class="codex-template__title" :title="item.title">{{ item.title }}</span>
- <span v-if="item.builtin" class="codex-badge codex-badge--muted">预置</span>
- <span class="codex-template__spacer"></span>
- <button class="codex-btn" type="button" @click="onPick(item)">插入</button>
- <button v-if="!item.builtin" class="codex-btn" type="button" @click="onRemove(item)">删除</button>
- </div>
- <p class="codex-template__prompt">{{ item.prompt }}</p>
- </article>
- </section>
- </a-drawer>
- </template>
- <script lang="ts" setup>
- /**
- * 分析模板抽屉:选一个模板填入输入框,或把当前草稿存成模板。
- *
- * 只负责「选」和「存」,不直接发送 —— 模板里常带 <占位符> 需要用户改完再发。
- */
- import { computed, nextTick, ref, watch } from 'vue';
- import { Button, Drawer, Input, Modal } from 'ant-design-vue';
- import { downloadByData } from '@/core/utils/file/download';
- import { useMessage } from '@/core/hooks/web/useMessage';
- import { codexTimestampTag } from '../utils/codexFormat';
- import {
- groupCodexTemplates,
- readCodexCustomTemplates,
- removeCodexTemplate,
- saveCodexTemplate,
- type CodexPromptTemplate,
- } from '../utils/codexTemplateLibrary';
- const props = withDefaults(defineProps<{ open: boolean; draft?: string }>(), { draft: '' });
- const emit = defineEmits<{
- (e: 'update:open', value: boolean): void;
- (e: 'pick', prompt: string): void;
- }>();
- const ADrawer = Drawer;
- const AInput = Input;
- const AButton = Button;
- const { showMessage } = useMessage();
- const keyword = ref('');
- const custom = ref<CodexPromptTemplate[]>(readCodexCustomTemplates());
- const saving = ref(false);
- const savingTitle = ref('');
- const titleInputRef = ref();
- const groups = computed(() => groupCodexTemplates(custom.value, keyword.value));
- function refresh() {
- custom.value = readCodexCustomTemplates();
- }
- // 每次打开都重读,避免多标签/外部改动导致列表过期
- watch(
- () => props.open,
- (open) => {
- if (open) {
- refresh();
- keyword.value = '';
- cancelSave();
- }
- },
- );
- function onPick(item: CodexPromptTemplate) {
- emit('pick', item.prompt);
- emit('update:open', false);
- }
- function startSave() {
- if (!props.draft.trim()) return;
- saving.value = true;
- // 默认名取草稿首行,用户直接回车即可保存
- savingTitle.value = props.draft.trim().split('\n')[0].slice(0, 20);
- nextTick(() => (titleInputRef.value as { focus?: () => void } | undefined)?.focus?.());
- }
- function cancelSave() {
- saving.value = false;
- savingTitle.value = '';
- }
- function confirmSave() {
- const prompt = props.draft.trim();
- const title = savingTitle.value.trim();
- if (!prompt || !title) return;
- custom.value = saveCodexTemplate({ title, prompt });
- cancelSave();
- showMessage('已存为模板', 'success');
- }
- function onRemove(item: CodexPromptTemplate) {
- custom.value = removeCodexTemplate(item.id);
- showMessage('已删除模板', 'success');
- }
- /* ---------------- 导出 / 导入(团队共享) ---------------- */
- const fileInputRef = ref<HTMLInputElement>();
- function onExport() {
- const list = readCodexCustomTemplates();
- if (!list.length) {
- showMessage('还没有自定义模板可导出(预置模板不需要导出)', 'warning');
- return;
- }
- downloadByData(
- JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), templates: list }, null, 2),
- `codex-templates-${codexTimestampTag()}.json`,
- 'application/json;charset=utf-8',
- );
- showMessage(`已导出 ${list.length} 个模板`, 'success');
- }
- function onImportClick() {
- fileInputRef.value?.click();
- }
- /** 真正写入:overwrite 决定同名模板是覆盖还是跳过 */
- function applyImport(incoming: CodexPromptTemplate[], overwrite: boolean) {
- let list = readCodexCustomTemplates();
- let added = 0;
- let replaced = 0;
- let skipped = 0;
- for (const item of incoming) {
- const title = String(item?.title ?? '').trim();
- const prompt = String(item?.prompt ?? '').trim();
- if (!title || !prompt) {
- skipped += 1;
- continue;
- }
- const conflict = list.some((exist) => exist.title === title);
- if (conflict && !overwrite) {
- skipped += 1;
- continue;
- }
- list = saveCodexTemplate({ title, prompt, group: item.group });
- if (conflict) replaced += 1;
- else added += 1;
- }
- custom.value = list;
- showMessage(`导入完成:新增 ${added}、覆盖 ${replaced}、跳过 ${skipped}`, 'success');
- }
- async function onFileChange(event: Event) {
- const input = event.target as HTMLInputElement;
- const file = input.files?.[0];
- // 清掉 value,否则连续选同一个文件不会再触发 change
- input.value = '';
- if (!file) return;
- try {
- const parsed = JSON.parse(await file.text());
- const incoming: CodexPromptTemplate[] = Array.isArray(parsed) ? parsed : parsed?.templates;
- if (!Array.isArray(incoming) || !incoming.length) throw new Error('文件里没有模板');
- const existingTitles = new Set(readCodexCustomTemplates().map((item) => item.title));
- const conflicts = incoming.filter((item) => item?.title && existingTitles.has(String(item.title)));
- if (conflicts.length) {
- Modal.confirm({
- title: `有 ${conflicts.length} 个同名模板`,
- content: `冲突模板:${conflicts
- .map((item) => String(item.title))
- .join('、')}。选择「覆盖」用文件里的内容替换,选择「跳过」保留现有模板。`,
- okType: 'primary',
- okButtonProps: { danger: true },
- okText: '覆盖',
- cancelText: '跳过',
- onOk: () => applyImport(incoming, true),
- onCancel: () => applyImport(incoming, false),
- });
- return;
- }
- applyImport(incoming, false);
- } catch (error: any) {
- showMessage(error?.message || '导入失败:文件格式不正确', 'error');
- }
- }
- </script>
- <style lang="less">
- .codex-template__bar {
- display: flex;
- align-items: center;
- gap: var(--codex-sp-2);
- margin-bottom: var(--codex-sp-2);
- }
- /* 导入用的隐藏 file input:不能 display:none —— 某些环境下点击会失效 */
- .codex-template__file {
- position: absolute;
- width: 1px;
- height: 1px;
- opacity: 0;
- pointer-events: none;
- }
- .codex-template__hint {
- margin: 0 0 var(--codex-sp-3);
- font-size: 11.5px;
- line-height: 1.7;
- color: var(--codex-text-3);
- }
- .codex-template__save-row {
- display: flex;
- align-items: center;
- gap: var(--codex-sp-2);
- margin-bottom: var(--codex-sp-3);
- padding: var(--codex-sp-2);
- background: var(--codex-bg-subtle);
- border: 1px solid var(--codex-border);
- border-radius: var(--codex-radius-sm);
- }
- .codex-template__group {
- margin-bottom: var(--codex-sp-4);
- }
- .codex-template__group-head {
- display: flex;
- align-items: baseline;
- gap: 6px;
- margin-bottom: var(--codex-sp-2);
- font-size: 11px;
- font-weight: 600;
- letter-spacing: 0.04em;
- text-transform: uppercase;
- color: var(--codex-text-2);
- border-bottom: 1px solid var(--codex-border);
- padding-bottom: 4px;
- }
- .codex-template__group-count {
- font-weight: 400;
- color: var(--codex-text-3);
- }
- .codex-template__item {
- padding: var(--codex-sp-2) 0;
- & + & {
- border-top: 1px dashed var(--codex-border);
- }
- }
- .codex-template__item-head {
- display: flex;
- align-items: center;
- gap: 6px;
- }
- .codex-template__title {
- font-size: 13px;
- font-weight: 600;
- color: var(--codex-text);
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- .codex-template__spacer {
- flex: 1 1 auto;
- }
- .codex-template__prompt {
- margin: 4px 0 0;
- font-size: 12px;
- line-height: 1.7;
- color: var(--codex-text-2);
- /* 模板正文可能带占位符与围栏示例,保留换行但压到 3 行,避免抽屉变成文章 */
- display: -webkit-box;
- -webkit-line-clamp: 3;
- -webkit-box-orient: vertical;
- overflow: hidden;
- white-space: pre-wrap;
- word-break: break-word;
- }
- </style>
|