codexTemplatePanel.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. <template>
  2. <a-drawer
  3. :open="open"
  4. title="分析模板库"
  5. wrap-class-name="codex-template"
  6. :width="520"
  7. @close="emit('update:open', false)"
  8. >
  9. <div class="codex-template__bar">
  10. <a-input v-model:value="keyword" size="small" allow-clear placeholder="搜索模板:标题 / 内容 / 分组" />
  11. <a-button size="small" :disabled="!draft.trim() || saving" @click="startSave">存为模板</a-button>
  12. <a-button size="small" @click="onExport">导出</a-button>
  13. <a-button size="small" @click="onImportClick">导入</a-button>
  14. <input
  15. ref="fileInputRef"
  16. class="codex-template__file"
  17. type="file"
  18. accept="application/json,.json"
  19. @change="onFileChange"
  20. />
  21. </div>
  22. <!-- 命名用行内表单:Electron 渲染进程不支持 window.prompt -->
  23. <div v-if="saving" class="codex-template__save-row">
  24. <a-input
  25. ref="titleInputRef"
  26. v-model:value="savingTitle"
  27. size="small"
  28. placeholder="模板名称"
  29. @keydown.enter="confirmSave"
  30. />
  31. <a-button size="small" type="primary" :disabled="!savingTitle.trim()" @click="confirmSave">保存</a-button>
  32. <a-button size="small" @click="cancelSave">取消</a-button>
  33. </div>
  34. <p v-else-if="!draft.trim()" class="codex-template__hint">
  35. 在输入框里写好提问后,回到这里可以把它存成模板,下次一键复用。
  36. </p>
  37. <div v-if="!groups.length" class="codex-placeholder">没有匹配的模板。</div>
  38. <section v-for="group in groups" :key="group.group" class="codex-template__group">
  39. <div class="codex-template__group-head">
  40. <span>{{ group.group }}</span>
  41. <span class="codex-template__group-count">{{ group.items.length }}</span>
  42. </div>
  43. <article v-for="item in group.items" :key="item.id" class="codex-template__item">
  44. <div class="codex-template__item-head">
  45. <span class="codex-template__title" :title="item.title">{{ item.title }}</span>
  46. <span v-if="item.builtin" class="codex-badge codex-badge--muted">预置</span>
  47. <span class="codex-template__spacer"></span>
  48. <button class="codex-btn" type="button" @click="onPick(item)">插入</button>
  49. <button v-if="!item.builtin" class="codex-btn" type="button" @click="onRemove(item)">删除</button>
  50. </div>
  51. <p class="codex-template__prompt">{{ item.prompt }}</p>
  52. </article>
  53. </section>
  54. </a-drawer>
  55. </template>
  56. <script lang="ts" setup>
  57. /**
  58. * 分析模板抽屉:选一个模板填入输入框,或把当前草稿存成模板。
  59. *
  60. * 只负责「选」和「存」,不直接发送 —— 模板里常带 <占位符> 需要用户改完再发。
  61. */
  62. import { computed, nextTick, ref, watch } from 'vue';
  63. import { Button, Drawer, Input, Modal } from 'ant-design-vue';
  64. import { downloadByData } from '@/core/utils/file/download';
  65. import { useMessage } from '@/core/hooks/web/useMessage';
  66. import { codexTimestampTag } from '../utils/codexFormat';
  67. import {
  68. groupCodexTemplates,
  69. readCodexCustomTemplates,
  70. removeCodexTemplate,
  71. saveCodexTemplate,
  72. type CodexPromptTemplate,
  73. } from '../utils/codexTemplateLibrary';
  74. const props = withDefaults(defineProps<{ open: boolean; draft?: string }>(), { draft: '' });
  75. const emit = defineEmits<{
  76. (e: 'update:open', value: boolean): void;
  77. (e: 'pick', prompt: string): void;
  78. }>();
  79. const ADrawer = Drawer;
  80. const AInput = Input;
  81. const AButton = Button;
  82. const { showMessage } = useMessage();
  83. const keyword = ref('');
  84. const custom = ref<CodexPromptTemplate[]>(readCodexCustomTemplates());
  85. const saving = ref(false);
  86. const savingTitle = ref('');
  87. const titleInputRef = ref();
  88. const groups = computed(() => groupCodexTemplates(custom.value, keyword.value));
  89. function refresh() {
  90. custom.value = readCodexCustomTemplates();
  91. }
  92. // 每次打开都重读,避免多标签/外部改动导致列表过期
  93. watch(
  94. () => props.open,
  95. (open) => {
  96. if (open) {
  97. refresh();
  98. keyword.value = '';
  99. cancelSave();
  100. }
  101. },
  102. );
  103. function onPick(item: CodexPromptTemplate) {
  104. emit('pick', item.prompt);
  105. emit('update:open', false);
  106. }
  107. function startSave() {
  108. if (!props.draft.trim()) return;
  109. saving.value = true;
  110. // 默认名取草稿首行,用户直接回车即可保存
  111. savingTitle.value = props.draft.trim().split('\n')[0].slice(0, 20);
  112. nextTick(() => (titleInputRef.value as { focus?: () => void } | undefined)?.focus?.());
  113. }
  114. function cancelSave() {
  115. saving.value = false;
  116. savingTitle.value = '';
  117. }
  118. function confirmSave() {
  119. const prompt = props.draft.trim();
  120. const title = savingTitle.value.trim();
  121. if (!prompt || !title) return;
  122. custom.value = saveCodexTemplate({ title, prompt });
  123. cancelSave();
  124. showMessage('已存为模板', 'success');
  125. }
  126. function onRemove(item: CodexPromptTemplate) {
  127. custom.value = removeCodexTemplate(item.id);
  128. showMessage('已删除模板', 'success');
  129. }
  130. /* ---------------- 导出 / 导入(团队共享) ---------------- */
  131. const fileInputRef = ref<HTMLInputElement>();
  132. function onExport() {
  133. const list = readCodexCustomTemplates();
  134. if (!list.length) {
  135. showMessage('还没有自定义模板可导出(预置模板不需要导出)', 'warning');
  136. return;
  137. }
  138. downloadByData(
  139. JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), templates: list }, null, 2),
  140. `codex-templates-${codexTimestampTag()}.json`,
  141. 'application/json;charset=utf-8',
  142. );
  143. showMessage(`已导出 ${list.length} 个模板`, 'success');
  144. }
  145. function onImportClick() {
  146. fileInputRef.value?.click();
  147. }
  148. /** 真正写入:overwrite 决定同名模板是覆盖还是跳过 */
  149. function applyImport(incoming: CodexPromptTemplate[], overwrite: boolean) {
  150. let list = readCodexCustomTemplates();
  151. let added = 0;
  152. let replaced = 0;
  153. let skipped = 0;
  154. for (const item of incoming) {
  155. const title = String(item?.title ?? '').trim();
  156. const prompt = String(item?.prompt ?? '').trim();
  157. if (!title || !prompt) {
  158. skipped += 1;
  159. continue;
  160. }
  161. const conflict = list.some((exist) => exist.title === title);
  162. if (conflict && !overwrite) {
  163. skipped += 1;
  164. continue;
  165. }
  166. list = saveCodexTemplate({ title, prompt, group: item.group });
  167. if (conflict) replaced += 1;
  168. else added += 1;
  169. }
  170. custom.value = list;
  171. showMessage(`导入完成:新增 ${added}、覆盖 ${replaced}、跳过 ${skipped}`, 'success');
  172. }
  173. async function onFileChange(event: Event) {
  174. const input = event.target as HTMLInputElement;
  175. const file = input.files?.[0];
  176. // 清掉 value,否则连续选同一个文件不会再触发 change
  177. input.value = '';
  178. if (!file) return;
  179. try {
  180. const parsed = JSON.parse(await file.text());
  181. const incoming: CodexPromptTemplate[] = Array.isArray(parsed) ? parsed : parsed?.templates;
  182. if (!Array.isArray(incoming) || !incoming.length) throw new Error('文件里没有模板');
  183. const existingTitles = new Set(readCodexCustomTemplates().map((item) => item.title));
  184. const conflicts = incoming.filter((item) => item?.title && existingTitles.has(String(item.title)));
  185. if (conflicts.length) {
  186. Modal.confirm({
  187. title: `有 ${conflicts.length} 个同名模板`,
  188. content: `冲突模板:${conflicts
  189. .map((item) => String(item.title))
  190. .join('、')}。选择「覆盖」用文件里的内容替换,选择「跳过」保留现有模板。`,
  191. okType: 'primary',
  192. okButtonProps: { danger: true },
  193. okText: '覆盖',
  194. cancelText: '跳过',
  195. onOk: () => applyImport(incoming, true),
  196. onCancel: () => applyImport(incoming, false),
  197. });
  198. return;
  199. }
  200. applyImport(incoming, false);
  201. } catch (error: any) {
  202. showMessage(error?.message || '导入失败:文件格式不正确', 'error');
  203. }
  204. }
  205. </script>
  206. <style lang="less">
  207. .codex-template__bar {
  208. display: flex;
  209. align-items: center;
  210. gap: var(--codex-sp-2);
  211. margin-bottom: var(--codex-sp-2);
  212. }
  213. /* 导入用的隐藏 file input:不能 display:none —— 某些环境下点击会失效 */
  214. .codex-template__file {
  215. position: absolute;
  216. width: 1px;
  217. height: 1px;
  218. opacity: 0;
  219. pointer-events: none;
  220. }
  221. .codex-template__hint {
  222. margin: 0 0 var(--codex-sp-3);
  223. font-size: 11.5px;
  224. line-height: 1.7;
  225. color: var(--codex-text-3);
  226. }
  227. .codex-template__save-row {
  228. display: flex;
  229. align-items: center;
  230. gap: var(--codex-sp-2);
  231. margin-bottom: var(--codex-sp-3);
  232. padding: var(--codex-sp-2);
  233. background: var(--codex-bg-subtle);
  234. border: 1px solid var(--codex-border);
  235. border-radius: var(--codex-radius-sm);
  236. }
  237. .codex-template__group {
  238. margin-bottom: var(--codex-sp-4);
  239. }
  240. .codex-template__group-head {
  241. display: flex;
  242. align-items: baseline;
  243. gap: 6px;
  244. margin-bottom: var(--codex-sp-2);
  245. font-size: 11px;
  246. font-weight: 600;
  247. letter-spacing: 0.04em;
  248. text-transform: uppercase;
  249. color: var(--codex-text-2);
  250. border-bottom: 1px solid var(--codex-border);
  251. padding-bottom: 4px;
  252. }
  253. .codex-template__group-count {
  254. font-weight: 400;
  255. color: var(--codex-text-3);
  256. }
  257. .codex-template__item {
  258. padding: var(--codex-sp-2) 0;
  259. & + & {
  260. border-top: 1px dashed var(--codex-border);
  261. }
  262. }
  263. .codex-template__item-head {
  264. display: flex;
  265. align-items: center;
  266. gap: 6px;
  267. }
  268. .codex-template__title {
  269. font-size: 13px;
  270. font-weight: 600;
  271. color: var(--codex-text);
  272. overflow: hidden;
  273. white-space: nowrap;
  274. text-overflow: ellipsis;
  275. }
  276. .codex-template__spacer {
  277. flex: 1 1 auto;
  278. }
  279. .codex-template__prompt {
  280. margin: 4px 0 0;
  281. font-size: 12px;
  282. line-height: 1.7;
  283. color: var(--codex-text-2);
  284. /* 模板正文可能带占位符与围栏示例,保留换行但压到 3 行,避免抽屉变成文章 */
  285. display: -webkit-box;
  286. -webkit-line-clamp: 3;
  287. -webkit-box-orient: vertical;
  288. overflow: hidden;
  289. white-space: pre-wrap;
  290. word-break: break-word;
  291. }
  292. </style>