feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
69a71aabbb
commit
e0baca15a9
225 files changed
+59337
No files matched your search
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
// FaqCard → MarkdownEditor → faqService → api → router → views → firebase,
|
||||
// which cannot initialize in the test environment.
|
||||
vi.mock('@/services/firebase', () => ({
|
||||
messaging: {},
|
||||
requestNotificationPermission: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/services/faq', () => ({
|
||||
faqService: { uploadImage: vi.fn() },
|
||||
}))
|
||||
|
||||
import FaqCard from '../../../components/faq/FaqCard.vue'
|
||||
import MarkdownEditor from '../../../components/faq/MarkdownEditor.vue'
|
||||
import type { FaqItem } from '../../../types/faq'
|
||||
|
||||
const faq: FaqItem = {
|
||||
id: 'f1',
|
||||
question: 'How does billing work?',
|
||||
answer: 'Plans are billed per seat, monthly or yearly.',
|
||||
category: 'Billing',
|
||||
status: 'published',
|
||||
knowledge_id: 1,
|
||||
source_label: 'Billing & refunds policy',
|
||||
}
|
||||
|
||||
const createWrapper = (props = {}) =>
|
||||
mount(FaqCard, {
|
||||
props: {
|
||||
faq,
|
||||
editing: false,
|
||||
draftQuestion: '',
|
||||
draftAnswer: '',
|
||||
...props,
|
||||
},
|
||||
})
|
||||
|
||||
describe('FaqCard display mode', () => {
|
||||
it('renders question, answer and source label', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.faq-card__question').text()).toBe('How does billing work?')
|
||||
expect(wrapper.find('.faq-card__answer').text()).toBe('Plans are billed per seat, monthly or yearly.')
|
||||
expect(wrapper.find('.faq-card__source').text()).toContain('Billing & refunds policy')
|
||||
})
|
||||
|
||||
it('falls back to Generated when there is no source label', () => {
|
||||
const wrapper = createWrapper({ faq: { ...faq, source_label: null } })
|
||||
expect(wrapper.find('.faq-card__source').text()).toContain('自动生成')
|
||||
})
|
||||
|
||||
it('shows the publish pill state', () => {
|
||||
const published = createWrapper()
|
||||
expect(published.find('.pill').classes()).toContain('pill--published')
|
||||
expect(published.find('.pill').text()).toContain('已发布')
|
||||
|
||||
const draft = createWrapper({ faq: { ...faq, status: 'draft' } })
|
||||
expect(draft.find('.pill').classes()).toContain('pill--draft')
|
||||
expect(draft.find('.pill').text()).toContain('草稿')
|
||||
})
|
||||
|
||||
it('emits toggle-status when the pill is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('.pill').trigger('click')
|
||||
expect(wrapper.emitted('toggle-status')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits edit and delete from the icon buttons', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('.icon-btn--edit').trigger('click')
|
||||
await wrapper.find('.icon-btn--delete').trigger('click')
|
||||
expect(wrapper.emitted('edit')).toHaveLength(1)
|
||||
expect(wrapper.emitted('delete')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hides edit and delete and disables the pill when locked', () => {
|
||||
const wrapper = createWrapper({ locked: true })
|
||||
expect(wrapper.find('.icon-btn--edit').exists()).toBe(false)
|
||||
expect(wrapper.find('.icon-btn--delete').exists()).toBe(false)
|
||||
expect(wrapper.find('.pill').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FaqCard edit mode', () => {
|
||||
it('renders inputs bound to the drafts', () => {
|
||||
const wrapper = createWrapper({
|
||||
editing: true,
|
||||
draftQuestion: 'Draft question?',
|
||||
draftAnswer: 'Draft answer.',
|
||||
})
|
||||
expect((wrapper.find('.edit-question').element as HTMLInputElement).value).toBe('Draft question?')
|
||||
// The answer is edited through the MarkdownEditor component.
|
||||
expect(wrapper.findComponent(MarkdownEditor).props('modelValue')).toBe('Draft answer.')
|
||||
expect(wrapper.find('.edit-label').text()).toBe('编辑中')
|
||||
})
|
||||
|
||||
it('labels a new FAQ', () => {
|
||||
const wrapper = createWrapper({ editing: true, isNew: true })
|
||||
expect(wrapper.find('.edit-label').text()).toBe('新建常见问题')
|
||||
})
|
||||
|
||||
it('emits draft updates on input', async () => {
|
||||
const wrapper = createWrapper({ editing: true })
|
||||
await wrapper.find('.edit-question').setValue('New question')
|
||||
wrapper.findComponent(MarkdownEditor).vm.$emit('update:modelValue', 'New answer')
|
||||
expect(wrapper.emitted('update:draftQuestion')?.[0]).toEqual(['New question'])
|
||||
expect(wrapper.emitted('update:draftAnswer')?.[0]).toEqual(['New answer'])
|
||||
})
|
||||
|
||||
it('renders the topic input with existing topics as autocomplete options', () => {
|
||||
const wrapper = createWrapper({
|
||||
editing: true,
|
||||
draftCategory: 'Billing',
|
||||
categories: ['Billing', 'Getting started'],
|
||||
})
|
||||
expect((wrapper.find('.edit-topic__input').element as HTMLInputElement).value).toBe('Billing')
|
||||
const options = wrapper.findAll('#faq-topic-options option')
|
||||
expect(options.map((o) => o.attributes('value'))).toEqual(['Billing', 'Getting started'])
|
||||
})
|
||||
|
||||
it('emits topic draft updates on input', async () => {
|
||||
const wrapper = createWrapper({ editing: true })
|
||||
await wrapper.find('.edit-topic__input').setValue('Refunds')
|
||||
expect(wrapper.emitted('update:draftCategory')?.[0]).toEqual(['Refunds'])
|
||||
})
|
||||
|
||||
it('emits save and cancel', async () => {
|
||||
const wrapper = createWrapper({ editing: true })
|
||||
await wrapper.find('.btn-save').trigger('click')
|
||||
await wrapper.find('.btn-cancel').trigger('click')
|
||||
expect(wrapper.emitted('save')).toHaveLength(1)
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('disables Save while saving', () => {
|
||||
const wrapper = createWrapper({ editing: true, saving: true })
|
||||
expect(wrapper.find('.btn-save').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('.btn-save').text()).toBe('保存中…')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import FaqGenerateBar from '../../../components/faq/FaqGenerateBar.vue'
|
||||
|
||||
const createWrapper = (props = {}) =>
|
||||
mount(FaqGenerateBar, {
|
||||
props: {
|
||||
phase: 'idle' as const,
|
||||
sourceCount: 5,
|
||||
pageCount: 9,
|
||||
faqCount: 12,
|
||||
publishedCount: 7,
|
||||
...props,
|
||||
},
|
||||
})
|
||||
|
||||
describe('FaqGenerateBar', () => {
|
||||
it('shows knowledge base counts and Generate when idle', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.generate-bar__title').text()).toBe('生成常见问题')
|
||||
expect(wrapper.find('.generate-bar__sub').text()).toContain('读取 5 个来源 · 9 个页面')
|
||||
expect(wrapper.find('.btn--generate').text()).toContain('生成')
|
||||
expect(wrapper.find('.btn--generate').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows FAQ counts and Regenerate when ready', () => {
|
||||
const wrapper = createWrapper({ phase: 'ready' })
|
||||
expect(wrapper.find('.generate-bar__title').text()).toBe('常见问题待发布')
|
||||
expect(wrapper.find('.generate-bar__sub').text()).toContain('12 条常见问题 · 7 条已发布')
|
||||
expect(wrapper.find('.btn--generate').text()).toContain('重新生成')
|
||||
})
|
||||
|
||||
it('disables the generate button while generating', () => {
|
||||
const wrapper = createWrapper({ phase: 'generating' })
|
||||
expect(wrapper.find('.generate-bar__title').text()).toBe('正在生成常见问题…')
|
||||
expect(wrapper.find('.btn--generate').text()).toContain('生成中…')
|
||||
expect(wrapper.find('.btn--generate').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('respects the disabled prop', () => {
|
||||
const wrapper = createWrapper({ disabled: true })
|
||||
expect(wrapper.find('.btn--generate').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('emits generate, import and add', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('.btn--generate').trigger('click')
|
||||
const ghostButtons = wrapper.findAll('.btn--ghost')
|
||||
await ghostButtons[0].trigger('click')
|
||||
await ghostButtons[1].trigger('click')
|
||||
expect(wrapper.emitted('generate')).toHaveLength(1)
|
||||
expect(wrapper.emitted('import')).toHaveLength(1)
|
||||
expect(wrapper.emitted('add')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not emit generate when disabled', async () => {
|
||||
const wrapper = createWrapper({ phase: 'generating' })
|
||||
await wrapper.find('.btn--generate').trigger('click')
|
||||
expect(wrapper.emitted('generate')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import FaqGenerationProgress from '../../../components/faq/FaqGenerationProgress.vue'
|
||||
import type { FaqGenerationJob, FaqJobStage } from '../../../types/faq'
|
||||
|
||||
const job = (stage: FaqJobStage): FaqGenerationJob => ({
|
||||
id: 1,
|
||||
job_type: 'generate_all',
|
||||
status: 'processing',
|
||||
stage,
|
||||
progress_percentage: 40,
|
||||
faqs_created: 0,
|
||||
source_url: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const stepStates = (stage: FaqJobStage) => {
|
||||
const wrapper = mount(FaqGenerationProgress, { props: { job: job(stage) } })
|
||||
return wrapper.findAll('.step').map((step) => {
|
||||
if (step.classes().includes('done')) return 'done'
|
||||
if (step.classes().includes('active')) return 'active'
|
||||
return 'todo'
|
||||
})
|
||||
}
|
||||
|
||||
describe('FaqGenerationProgress', () => {
|
||||
it('renders four steps with the expected labels', () => {
|
||||
const wrapper = mount(FaqGenerationProgress, { props: { job: job('analyzing_sources') } })
|
||||
const labels = wrapper.findAll('.step__label').map((el) => el.text())
|
||||
expect(labels).toEqual([
|
||||
'分析你的来源',
|
||||
'从内容中提取问题',
|
||||
'撰写有依据的回答',
|
||||
'按分类整理',
|
||||
])
|
||||
})
|
||||
|
||||
it('marks earlier stages done, the current stage active, later stages todo', () => {
|
||||
expect(stepStates('drafting')).toEqual(['done', 'done', 'active', 'todo'])
|
||||
})
|
||||
|
||||
it('starts with the first stage active', () => {
|
||||
expect(stepStates('analyzing_sources')).toEqual(['active', 'todo', 'todo', 'todo'])
|
||||
expect(stepStates('not_started')).toEqual(['active', 'todo', 'todo', 'todo'])
|
||||
})
|
||||
|
||||
it('marks everything done when completed', () => {
|
||||
expect(stepStates('completed')).toEqual(['done', 'done', 'done', 'done'])
|
||||
})
|
||||
|
||||
it('shows pulsing dots only on the active step', () => {
|
||||
const wrapper = mount(FaqGenerationProgress, { props: { job: job('drafting') } })
|
||||
const steps = wrapper.findAll('.step')
|
||||
expect(steps[2].find('.step__pulse').exists()).toBe(true)
|
||||
expect(steps[0].find('.step__pulse').exists()).toBe(false)
|
||||
expect(steps[3].find('.step__pulse').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import FaqImportModal from '../../../components/faq/FaqImportModal.vue'
|
||||
|
||||
vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||
|
||||
const createWrapper = () =>
|
||||
mount(FaqImportModal, {
|
||||
props: { open: true },
|
||||
global: {
|
||||
stubs: {
|
||||
Modal: { template: '<div><slot name="title" /><slot name="content" /></div>' },
|
||||
FaqOrb: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/** Pick an import mode by its radio value. */
|
||||
const chooseMode = (wrapper: ReturnType<typeof createWrapper>, mode: string) =>
|
||||
wrapper.find(`input[value="${mode}"]`).setValue(true)
|
||||
|
||||
describe('FaqImportModal', () => {
|
||||
it('offers the preserve-URLs option only for an article migration', async () => {
|
||||
const wrapper = createWrapper()
|
||||
// Q&A and PDF modes extract answers from one document — there are no
|
||||
// per-article source URLs to preserve.
|
||||
expect(wrapper.find('.preserve').exists()).toBe(false)
|
||||
|
||||
await chooseMode(wrapper, 'articles')
|
||||
expect(wrapper.find('.preserve').exists()).toBe(true)
|
||||
|
||||
await chooseMode(wrapper, 'pdf')
|
||||
expect(wrapper.find('.preserve').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves URLs by default, since that is what a migration wants', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await chooseMode(wrapper, 'articles')
|
||||
await wrapper.find('#faq-import-url').setValue('support.acme.com/help')
|
||||
|
||||
await wrapper.find('.btn-import').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('submit')?.[0]).toEqual([
|
||||
'https://support.acme.com/help',
|
||||
'articles',
|
||||
true,
|
||||
])
|
||||
})
|
||||
|
||||
it('submits the opt-out when the box is unticked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await chooseMode(wrapper, 'articles')
|
||||
await wrapper.find('#faq-import-url').setValue('support.acme.com/help')
|
||||
await wrapper.find('.preserve__box').setValue(false)
|
||||
|
||||
await wrapper.find('.btn-import').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('submit')?.[0]?.[2]).toBe(false)
|
||||
})
|
||||
|
||||
it('never claims to preserve URLs in a mode that has none', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('#faq-import-url').setValue('support.acme.com/faq')
|
||||
|
||||
await wrapper.find('.btn-import').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('submit')?.[0]).toEqual(['https://support.acme.com/faq', 'qa', false])
|
||||
})
|
||||
|
||||
it('resets the choice when the mode changes, so it cannot go stale', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await chooseMode(wrapper, 'articles')
|
||||
await wrapper.find('.preserve__box').setValue(false)
|
||||
|
||||
await chooseMode(wrapper, 'qa')
|
||||
await chooseMode(wrapper, 'articles')
|
||||
|
||||
expect((wrapper.find('.preserve__box').element as HTMLInputElement).checked).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import FaqSeoFields from '../../../components/faq/FaqSeoFields.vue'
|
||||
|
||||
const createWrapper = (props = {}) => mount(FaqSeoFields, { props })
|
||||
|
||||
// The meta-title input is the only plain .seo__input without a modifier class.
|
||||
const titleInput = (wrapper: ReturnType<typeof createWrapper>) =>
|
||||
wrapper.findAll('input.seo__input').filter((i) => !i.classes().some((c) => c.includes('--')))[0]
|
||||
|
||||
describe('FaqSeoFields', () => {
|
||||
it('stays collapsed until opened when nothing is customised', async () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.seo__fields').exists()).toBe(false)
|
||||
|
||||
await wrapper.find('.seo__toggle').trigger('click')
|
||||
expect(wrapper.find('.seo__fields').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('opens itself when the article already has overrides', () => {
|
||||
// A customised value must never be hidden behind a collapsed panel.
|
||||
const wrapper = createWrapper({ metaTitle: 'Custom title' })
|
||||
expect(wrapper.find('.seo__fields').exists()).toBe(true)
|
||||
expect(wrapper.find('.seo__badge').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('can still be collapsed after opening itself', async () => {
|
||||
const wrapper = createWrapper({ metaTitle: 'Custom title' })
|
||||
await wrapper.find('.seo__toggle').trigger('click')
|
||||
expect(wrapper.find('.seo__fields').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not treat a slug alone as customised', () => {
|
||||
// Every article gets a generated slug, so its presence says nothing about
|
||||
// whether the org edited anything.
|
||||
const wrapper = createWrapper({ slug: 'auto-generated-slug' })
|
||||
expect(wrapper.find('.seo__badge').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits updates for each field', async () => {
|
||||
const wrapper = createWrapper({ metaTitle: 'x' })
|
||||
|
||||
await wrapper.find('.seo__input--slug').setValue('custom-slug')
|
||||
await wrapper.find('.seo__input--path').setValue('/hc/articles/1')
|
||||
await titleInput(wrapper).setValue('New title')
|
||||
await wrapper.find('textarea').setValue('New description')
|
||||
|
||||
expect(wrapper.emitted('update:slug')?.[0]).toEqual(['custom-slug'])
|
||||
expect(wrapper.emitted('update:urlPath')?.[0]).toEqual(['/hc/articles/1'])
|
||||
expect(wrapper.emitted('update:metaTitle')?.at(-1)).toEqual(['New title'])
|
||||
expect(wrapper.emitted('update:metaDescription')?.[0]).toEqual(['New description'])
|
||||
})
|
||||
|
||||
it('treats a preserved URL path as a customisation', () => {
|
||||
// A migrated article's URL must not be hidden behind a collapsed panel.
|
||||
const wrapper = createWrapper({ urlPath: '/hc/en-us/articles/360012-reset' })
|
||||
expect(wrapper.find('.seo__fields').exists()).toBe(true)
|
||||
expect(wrapper.find('.seo__badge').exists()).toBe(true)
|
||||
expect((wrapper.find('.seo__input--path').element as HTMLInputElement).value).toBe(
|
||||
'/hc/en-us/articles/360012-reset',
|
||||
)
|
||||
})
|
||||
|
||||
it('flags a title past the length search engines truncate at', () => {
|
||||
const wrapper = createWrapper({ metaTitle: 'a'.repeat(61) })
|
||||
expect(wrapper.find('.seo__count--over').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the question as the title placeholder so the default is visible', () => {
|
||||
const wrapper = createWrapper({ metaTitle: 'x', question: 'How do I sign up?' })
|
||||
expect(titleInput(wrapper).attributes('placeholder')).toBe('How do I sign up?')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import CopyField from '../../../components/integrations/CopyField.vue'
|
||||
|
||||
vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||
|
||||
const mountField = () => mount(CopyField, {
|
||||
props: { fieldId: 'verify-token', label: 'Verify token', name: 'Verify token', value: 'tok-123' },
|
||||
})
|
||||
|
||||
describe('CopyField', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('labels its input', () => {
|
||||
const wrapper = mountField()
|
||||
expect(wrapper.find('label').attributes('for')).toBe('verify-token')
|
||||
expect(wrapper.find('input').attributes('id')).toBe('verify-token')
|
||||
})
|
||||
|
||||
it('copies the value', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(navigator, { clipboard: { writeText } })
|
||||
await mountField().find('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(writeText).toHaveBeenCalledWith('tok-123')
|
||||
expect(toast.success).toHaveBeenCalledWith('Verify token已复制')
|
||||
})
|
||||
|
||||
it('says so when the browser will not copy', async () => {
|
||||
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error('insecure')) } })
|
||||
await mountField().find('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import MessengerPagePicker from '../../../components/integrations/MessengerPagePicker.vue'
|
||||
|
||||
const PAGES = [
|
||||
{ id: 'PAGE1', name: 'Acme Support' },
|
||||
{ id: 'PAGE2', name: 'Acme Sales' },
|
||||
]
|
||||
|
||||
const mountPicker = (connecting = false) =>
|
||||
mount(MessengerPagePicker, {
|
||||
props: { pages: PAGES, connecting },
|
||||
global: { stubs: { 'font-awesome-icon': true } },
|
||||
})
|
||||
|
||||
describe('MessengerPagePicker', () => {
|
||||
it('renders one row per Page', () => {
|
||||
const wrapper = mountPicker()
|
||||
expect(wrapper.findAll('.picker-row')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('Acme Support')
|
||||
expect(wrapper.text()).toContain('Acme Sales')
|
||||
})
|
||||
|
||||
it('emits select with the picked Page id', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await wrapper.findAll('.picker-row')[1].trigger('click')
|
||||
expect(wrapper.emitted('select')).toEqual([['PAGE2']])
|
||||
})
|
||||
|
||||
it('locks every row while a connect is in flight', () => {
|
||||
const wrapper = mountPicker(true)
|
||||
const rows = wrapper.findAll('.picker-row')
|
||||
expect(rows.every((row) => row.attributes('disabled') !== undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
import MetaChannelConnect from '../../../components/integrations/MetaChannelConnect.vue'
|
||||
import channelsService from '../../../services/channels'
|
||||
|
||||
vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||
vi.mock('../../../services/channels', () => ({
|
||||
default: { connectWhatsApp: vi.fn(), connectMessenger: vi.fn(), connectInstagram: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../../services/agent', () => ({ agentService: { getOrganizationAgents: vi.fn().mockResolvedValue([]) } }))
|
||||
// The manual form, as when Meta's one-click signup isn't offered
|
||||
vi.mock('../../../composables/useMetaSignup', () => ({
|
||||
useMetaSignup: () => ({
|
||||
signupEnabled: ref(false), signingUp: ref(false), showManualForm: ref(true), signupPages: ref([]),
|
||||
connectingPage: ref(false), copy: { intro: '', cta: '' }, startSignup: vi.fn(), onPageSelected: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const OWN_APP_ACCOUNT = {
|
||||
id: 'acc-1', channel_type: 'whatsapp', external_account_id: 'PN9', is_active: true, display_name: 'Acme',
|
||||
webhook_url: 'https://api.example.com/api/v1/webhooks/meta/acc-1', webhook_verify_token: 'tok', webhook_auto_configured: false,
|
||||
}
|
||||
|
||||
const fill = async (wrapper: ReturnType<typeof mount>, values: Record<string, string>) => {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await wrapper.find(`#meta-${key}`).setValue(value)
|
||||
}
|
||||
await wrapper.findAll('button').find(b => b.text() === '连接')!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('MetaChannelConnect app secret', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('connects without a secret and sends none', async () => {
|
||||
vi.mocked(channelsService.connectWhatsApp).mockResolvedValue({ ...OWN_APP_ACCOUNT, webhook_url: undefined } as any)
|
||||
const wrapper = mount(MetaChannelConnect, { props: { channel: 'whatsapp' }, global: { stubs: { 'font-awesome-icon': true } } })
|
||||
await fill(wrapper, { phone_number_id: 'PN9', access_token: 'EAAG-x' })
|
||||
expect(channelsService.connectWhatsApp).toHaveBeenCalledWith({ phone_number_id: 'PN9', access_token: 'EAAG-x' })
|
||||
expect(wrapper.text()).not.toContain('回调 URL')
|
||||
})
|
||||
|
||||
it('sends the secret and shows where webhooks go', async () => {
|
||||
vi.mocked(channelsService.connectWhatsApp).mockResolvedValue(OWN_APP_ACCOUNT as any)
|
||||
const wrapper = mount(MetaChannelConnect, { props: { channel: 'whatsapp' }, global: { stubs: { 'font-awesome-icon': true } } })
|
||||
await fill(wrapper, { phone_number_id: 'PN9', access_token: 'EAAG-x', app_secret: ' s3cret ' })
|
||||
expect(channelsService.connectWhatsApp).toHaveBeenCalledWith(
|
||||
{ phone_number_id: 'PN9', access_token: 'EAAG-x', app_secret: 's3cret' })
|
||||
expect(wrapper.text()).toContain('回调 URL')
|
||||
expect(wrapper.text()).toContain('WhatsApp → 配置')
|
||||
})
|
||||
|
||||
it('offers the secret on Messenger too, not Instagram', () => {
|
||||
const has = (channel: 'messenger' | 'instagram') =>
|
||||
mount(MetaChannelConnect, { props: { channel }, global: { stubs: { 'font-awesome-icon': true } } })
|
||||
.find('#meta-app_secret').exists()
|
||||
expect(has('messenger')).toBe(true)
|
||||
expect(has('instagram')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/services/agent', () => ({
|
||||
agentService: { getOrganizationAgents: vi.fn().mockResolvedValue([{ id: 'ag1', name: 'Bot' }]) },
|
||||
}))
|
||||
vi.mock('@/services/channels', () => ({
|
||||
default: {
|
||||
getEmbeddedSignupConfig: vi.fn().mockResolvedValue({ enabled: false }),
|
||||
connectWhatsApp: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import channelsService from '@/services/channels'
|
||||
import MetaChannelConnect from '../../../components/integrations/MetaChannelConnect.vue'
|
||||
import type { ChannelAccount } from '@/services/channels'
|
||||
|
||||
const ACCOUNT: ChannelAccount = {
|
||||
id: 'acc1', channel_type: 'whatsapp', external_account_id: 'PN1',
|
||||
display_name: 'Test Number', is_active: true, agent_id: 'ag1',
|
||||
}
|
||||
|
||||
const mountModal = (existingAccount: ChannelAccount) =>
|
||||
mount(MetaChannelConnect, {
|
||||
props: { channel: 'whatsapp', existingAccount },
|
||||
global: { stubs: { 'font-awesome-icon': true } },
|
||||
})
|
||||
|
||||
describe('MetaChannelConnect reconnect prompt', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('does not offer a reconnect for a healthy account', async () => {
|
||||
const wrapper = mountModal(ACCOUNT)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.meta-reconnect').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('已连接')
|
||||
})
|
||||
|
||||
it('offers a reconnect when Meta stopped accepting the account', async () => {
|
||||
const wrapper = mountModal({ ...ACCOUNT, needs_reconnect: true })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.meta-reconnect').text()).toContain('请重新连接以恢复回复')
|
||||
})
|
||||
|
||||
it('Reconnect opens the credentials form, and connecting again clears the prompt', async () => {
|
||||
vi.mocked(channelsService.connectWhatsApp).mockResolvedValue({ ...ACCOUNT, needs_reconnect: false })
|
||||
const wrapper = mountModal({ ...ACCOUNT, needs_reconnect: true })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.meta-reconnect button').trigger('click')
|
||||
expect(wrapper.find('#meta-phone_number_id').exists()).toBe(true)
|
||||
|
||||
await wrapper.find('#meta-phone_number_id').setValue('PN1')
|
||||
await wrapper.find('#meta-access_token').setValue('new-token')
|
||||
await wrapper.findAll('.meta-actions .meta-btn-primary').at(-1)!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(channelsService.connectWhatsApp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ phone_number_id: 'PN1', access_token: 'new-token' }))
|
||||
expect(wrapper.find('.meta-reconnect').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('已连接')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import MetaOwnAppWebhook from '../../../components/integrations/MetaOwnAppWebhook.vue'
|
||||
import type { ChannelAccount } from '../../../services/channels'
|
||||
|
||||
vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||
|
||||
const account = (channel_type: 'whatsapp' | 'messenger', webhook_auto_configured?: boolean): ChannelAccount => ({
|
||||
id: 'acc-1',
|
||||
channel_type,
|
||||
external_account_id: 'PN1',
|
||||
is_active: true,
|
||||
webhook_url: 'https://api.example.com/api/v1/webhooks/meta/acc-1',
|
||||
webhook_verify_token: 'tok-123',
|
||||
webhook_auto_configured,
|
||||
})
|
||||
|
||||
const text = (a: ChannelAccount) => mount(MetaOwnAppWebhook, { props: { account: a } }).text()
|
||||
|
||||
describe('MetaOwnAppWebhook', () => {
|
||||
it('shows the callback URL and verify token', () => {
|
||||
const inputs = mount(MetaOwnAppWebhook, { props: { account: account('whatsapp', true) } }).findAll('input')
|
||||
expect(inputs.map(i => (i.element as HTMLInputElement).value)).toEqual([
|
||||
'https://api.example.com/api/v1/webhooks/meta/acc-1', 'tok-123'])
|
||||
})
|
||||
|
||||
it('says a WhatsApp number is set up only when Meta took the callback', () => {
|
||||
expect(text(account('whatsapp', true))).toContain('无需额外配置')
|
||||
expect(text(account('whatsapp', false))).toContain('WhatsApp → 配置')
|
||||
})
|
||||
|
||||
it('tells Messenger where the values go', () => {
|
||||
expect(text(account('messenger'))).toContain('Messenger → 设置 → Webhooks')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import BottomNav from '@/components/layout/BottomNav.vue'
|
||||
|
||||
// Nav visibility comes from the real permission checks reading the cached
|
||||
// user, so give this spec a user who holds everything.
|
||||
vi.mock('@/services/user', async () => {
|
||||
const { userWithPermissions } = await import('../../fixtures/permissions')
|
||||
const user = userWithPermissions([
|
||||
'manage_agents', 'manage_users', 'view_all_chats', 'manage_all_chats', 'view_people',
|
||||
'manage_knowledge', 'view_analytics', 'view_tickets', 'manage_organization',
|
||||
'manage_ai_config', 'manage_subscription',
|
||||
])
|
||||
return { userService: { getCurrentUser: () => user } }
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useEnterpriseFeatures', () => ({
|
||||
useEnterpriseFeatures: () => ({ hasEnterpriseModule: false }),
|
||||
}))
|
||||
|
||||
const routes = ['/conversations', '/people', '/ai-agents', '/analytics', '/knowledge'].map(
|
||||
(path) => ({ path, component: { template: '<div />' } })
|
||||
)
|
||||
routes.push({ path: '/', component: { template: '<div />' } })
|
||||
|
||||
const makeRouter = () => createRouter({ history: createWebHistory(), routes })
|
||||
|
||||
const mountNav = async (props = {}, path = '/conversations') => {
|
||||
const router = makeRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
return mount(BottomNav, { props, global: { plugins: [router] } })
|
||||
}
|
||||
|
||||
describe('BottomNav', () => {
|
||||
it('renders the four primary items plus More', async () => {
|
||||
const wrapper = await mountNav()
|
||||
const labels = wrapper.findAll('.bottom-nav-label').map((n) => n.text())
|
||||
expect(labels).toEqual(['会话', '联系人', 'AI 客服', '数据分析', '更多'])
|
||||
})
|
||||
|
||||
it('marks the current route active', async () => {
|
||||
const wrapper = await mountNav({}, '/analytics')
|
||||
const active = wrapper.findAll('.bottom-nav-item.active')
|
||||
expect(active).toHaveLength(1)
|
||||
expect(active[0].text()).toContain('数据分析')
|
||||
})
|
||||
|
||||
it('marks More active when on an overflow route', async () => {
|
||||
const wrapper = await mountNav({}, '/knowledge')
|
||||
const active = wrapper.findAll('.bottom-nav-item.active')
|
||||
expect(active).toHaveLength(1)
|
||||
expect(active[0].text()).toContain('更多')
|
||||
})
|
||||
|
||||
it('shows an unread badge on More and caps it at 99+', async () => {
|
||||
const wrapper = await mountNav({ unreadCount: 3 })
|
||||
expect(wrapper.find('.bottom-nav-badge').text()).toBe('3')
|
||||
|
||||
await wrapper.setProps({ unreadCount: 120 })
|
||||
expect(wrapper.find('.bottom-nav-badge').text()).toBe('99+')
|
||||
|
||||
await wrapper.setProps({ unreadCount: 0 })
|
||||
expect(wrapper.find('.bottom-nav-badge').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits more when the More button is tapped', async () => {
|
||||
const wrapper = await mountNav()
|
||||
await wrapper.find('button.bottom-nav-item').trigger('click')
|
||||
expect(wrapper.emitted('more')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Drive the REAL permission checks from a cached user, rather than mocking
|
||||
// permissionChecks. Nav visibility and the router now read the same map, and a
|
||||
// mock of the checks cannot catch the two disagreeing — which is exactly the
|
||||
// bug this suite missed: People was listed for anyone with a chat grant while
|
||||
// the route required view_people, so the link bounced.
|
||||
const currentUser = await vi.hoisted(async () => ({ value: null as unknown }))
|
||||
|
||||
vi.mock('@/services/user', () => ({
|
||||
userService: { getCurrentUser: () => currentUser.value },
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useEnterpriseFeatures', () => ({
|
||||
useEnterpriseFeatures: () => ({ hasEnterpriseModule: false }),
|
||||
}))
|
||||
|
||||
import { useNavItems, PRIMARY_NAV_PATHS } from '@/components/layout/navItems'
|
||||
import { userWithPermissions, HUMAN_AGENT_PERMISSIONS } from '../../fixtures/permissions'
|
||||
|
||||
const ALL_PERMISSIONS = [
|
||||
'manage_agents', 'view_agents', 'manage_users', 'view_all_chats', 'manage_all_chats',
|
||||
'view_assigned_chats', 'manage_assigned_chats', 'view_unassigned_chats', 'view_people',
|
||||
'manage_knowledge', 'view_knowledge', 'view_analytics', 'view_tickets', 'manage_tickets',
|
||||
'manage_organization', 'view_organization', 'manage_ai_config', 'view_ai_config',
|
||||
'manage_subscription', 'view_subscription',
|
||||
]
|
||||
|
||||
const asUser = (permissions: string[]) => {
|
||||
currentUser.value = userWithPermissions(permissions)
|
||||
}
|
||||
|
||||
describe('useNavItems', () => {
|
||||
beforeEach(() => asUser(ALL_PERMISSIONS))
|
||||
|
||||
it('splits primary (bottom-nav) and overflow (More sheet) items', () => {
|
||||
const { primaryNavItems, moreNavItems } = useNavItems()
|
||||
|
||||
expect(primaryNavItems.value.map((i) => i.to)).toEqual([
|
||||
'/conversations',
|
||||
'/people',
|
||||
'/ai-agents',
|
||||
'/analytics',
|
||||
])
|
||||
const morePaths = moreNavItems.value.map((i) => i.to)
|
||||
expect(morePaths).toContain('/human-agents')
|
||||
expect(morePaths).toContain('/knowledge')
|
||||
expect(morePaths).toContain('/settings/user')
|
||||
morePaths.forEach((p) => expect(PRIMARY_NAV_PATHS).not.toContain(p))
|
||||
moreNavItems.value.forEach((i) => expect(i.section).toBeUndefined())
|
||||
})
|
||||
|
||||
it('hides permission-gated items for restricted users', () => {
|
||||
asUser(['manage_agents'])
|
||||
|
||||
const { primaryNavItems, moreNavItems, navItems } = useNavItems()
|
||||
|
||||
const primaryPaths = primaryNavItems.value.map((i) => i.to)
|
||||
expect(primaryPaths).not.toContain('/conversations')
|
||||
expect(primaryPaths).not.toContain('/people')
|
||||
expect(primaryPaths).not.toContain('/analytics')
|
||||
expect(primaryPaths).toContain('/ai-agents')
|
||||
|
||||
expect(moreNavItems.value.map((i) => i.to)).not.toContain('/human-agents')
|
||||
// User settings is always available
|
||||
expect(navItems.value.map((i) => i.to)).toContain('/settings/user')
|
||||
})
|
||||
|
||||
// The product requirement, as a test: a Human Agent sees the inbox, the
|
||||
// people they talk to, and their own settings. Nothing else.
|
||||
it('shows a Human Agent exactly Inbox, People and User Settings', () => {
|
||||
asUser(HUMAN_AGENT_PERMISSIONS)
|
||||
|
||||
const { navItems } = useNavItems()
|
||||
|
||||
expect(navItems.value.filter((i) => i.to).map((i) => i.to)).toEqual([
|
||||
'/conversations',
|
||||
'/people',
|
||||
'/settings/user',
|
||||
])
|
||||
})
|
||||
|
||||
// A role with only view_people gets the People link; one with only a chat
|
||||
// grant does not. The nav used to gate People on chat permissions while the
|
||||
// route required view_people, so both cases were wrong in opposite ways.
|
||||
it('gates People on the same permissions as its route', () => {
|
||||
asUser(['view_people'])
|
||||
expect(useNavItems().navItems.value.map((i) => i.to)).toContain('/people')
|
||||
|
||||
asUser(['view_assigned_chats'])
|
||||
expect(useNavItems().navItems.value.map((i) => i.to)).toContain('/people')
|
||||
|
||||
asUser(['manage_knowledge'])
|
||||
expect(useNavItems().navItems.value.map((i) => i.to)).not.toContain('/people')
|
||||
})
|
||||
|
||||
// super_admin bypasses every check on the backend; a frontend check that
|
||||
// does not bypass gives that role an almost-empty sidebar.
|
||||
it('shows everything to a super_admin', () => {
|
||||
asUser(['super_admin'])
|
||||
|
||||
const paths = useNavItems().navItems.value.filter((i) => i.to).map((i) => i.to)
|
||||
|
||||
expect(paths).toContain('/ai-agents')
|
||||
expect(paths).toContain('/human-agents')
|
||||
expect(paths).toContain('/knowledge')
|
||||
expect(paths).toContain('/settings/organization')
|
||||
})
|
||||
|
||||
it('excludes enterprise subscription when enterprise module is absent', () => {
|
||||
const { moreNavItems } = useNavItems()
|
||||
expect(moreNavItems.value.map((i) => i.to)).not.toContain('/settings/subscription')
|
||||
})
|
||||
|
||||
// Guards the whole point of the More sheet: mobile must expose exactly the
|
||||
// same destinations as the desktop sidebar, so adding a nav item can never
|
||||
// silently leave phones without it.
|
||||
it('surfaces every sidebar destination across the bottom nav and More sheet', () => {
|
||||
const { navItems, primaryNavItems, moreNavGroups } = useNavItems()
|
||||
|
||||
const sidebarPaths = navItems.value.filter((i) => i.to).map((i) => i.to)
|
||||
const mobilePaths = [
|
||||
...primaryNavItems.value.map((i) => i.to),
|
||||
...moreNavGroups.value.flatMap((g) => g.items.map((i) => i.to)),
|
||||
]
|
||||
|
||||
expect(mobilePaths.slice().sort()).toEqual(sidebarPaths.slice().sort())
|
||||
})
|
||||
|
||||
it('keeps the sidebar section headings in the More sheet', () => {
|
||||
const { moreNavGroups } = useNavItems()
|
||||
expect(moreNavGroups.value.map((g) => g.section)).toEqual(['主菜单', '设置'])
|
||||
// No empty groups — a section fully covered by the bottom nav is dropped
|
||||
moreNavGroups.value.forEach((g) => expect(g.items.length).toBeGreaterThan(0))
|
||||
})
|
||||
|
||||
// User Settings is always visible while the Settings heading used to be
|
||||
// permission-gated separately, which orphaned the item into Main Menu.
|
||||
it('keeps always-visible settings items under Settings for restricted users', () => {
|
||||
// Knowledge, so Main Menu still has a non-primary item to group; no
|
||||
// settings grants, so Settings holds only the always-visible entry.
|
||||
asUser(['manage_knowledge'])
|
||||
|
||||
const { moreNavGroups } = useNavItems()
|
||||
const settings = moreNavGroups.value.find((g) => g.section === '设置')
|
||||
|
||||
expect(settings?.items.map((i) => i.to)).toEqual(['/settings/user'])
|
||||
expect(moreNavGroups.value.find((g) => g.section === '主菜单')?.items.map((i) => i.to))
|
||||
.not.toContain('/settings/user')
|
||||
})
|
||||
|
||||
it('drops a section whose every item is permission-hidden', () => {
|
||||
asUser([])
|
||||
|
||||
const { moreNavGroups, navItems } = useNavItems()
|
||||
|
||||
// Only User Settings survives, so Main Menu disappears entirely
|
||||
expect(moreNavGroups.value.map((g) => g.section)).toEqual(['设置'])
|
||||
expect(navItems.value.filter((i) => i.section).map((i) => i.section)).toEqual(['设置'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import InvestigationNoteForm from '@/components/tickets/InvestigationNoteForm.vue'
|
||||
|
||||
// attachTo puts the component in the real document so focus can be asserted,
|
||||
// and VTU only takes it back out on unmount — without this the forms pile up
|
||||
// and whichever mounted last owns document.activeElement.
|
||||
const mounted: VueWrapper[] = []
|
||||
afterEach(() => {
|
||||
while (mounted.length) mounted.pop()!.unmount()
|
||||
})
|
||||
|
||||
const mountForm = (props: Record<string, unknown> = {}, slots: Record<string, string> = {}) => {
|
||||
const wrapper = mount(InvestigationNoteForm, {
|
||||
props: { placeholder: 'Why?', submitLabel: 'Go', ...props },
|
||||
slots,
|
||||
attachTo: document.body,
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('InvestigationNoteForm', () => {
|
||||
it('trims the note before emitting', async () => {
|
||||
const wrapper = mountForm()
|
||||
await wrapper.find('textarea').setValue(' use fields.order_ref ')
|
||||
await wrapper.find('.submit-btn').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('submit')?.[0]).toEqual(['use fields.order_ref'])
|
||||
})
|
||||
|
||||
it('allows an empty note', async () => {
|
||||
// Re-running unchanged is legitimate, and the reject flow has always
|
||||
// permitted a reason-less rejection.
|
||||
const wrapper = mountForm()
|
||||
await wrapper.find('.submit-btn').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('submit')?.[0]).toEqual([''])
|
||||
})
|
||||
|
||||
it('submits on Cmd+Enter and Ctrl+Enter', async () => {
|
||||
const meta = mountForm()
|
||||
await meta.find('textarea').setValue('a')
|
||||
await meta.find('textarea').trigger('keydown', { key: 'Enter', metaKey: true })
|
||||
expect(meta.emitted('submit')?.[0]).toEqual(['a'])
|
||||
|
||||
const ctrl = mountForm()
|
||||
await ctrl.find('textarea').setValue('b')
|
||||
await ctrl.find('textarea').trigger('keydown', { key: 'Enter', ctrlKey: true })
|
||||
expect(ctrl.emitted('submit')?.[0]).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('cancels on Escape', async () => {
|
||||
const wrapper = mountForm()
|
||||
await wrapper.find('textarea').trigger('keydown', { key: 'Escape' })
|
||||
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
expect(wrapper.emitted('submit')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('focuses the textarea so the operator can just type', () => {
|
||||
// Mounted alone in this test, so activeElement can only be this one.
|
||||
const wrapper = mountForm()
|
||||
expect(document.activeElement).toBe(wrapper.find('textarea').element)
|
||||
})
|
||||
|
||||
it('blocks a second submit while one is in flight', async () => {
|
||||
const wrapper = mountForm({ busy: true })
|
||||
expect(wrapper.find('.submit-btn').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('colours the action by tone', () => {
|
||||
expect(mountForm({ tone: 'danger' }).find('.submit-btn').classes()).toContain('danger')
|
||||
// Accent is the default, so the panel's re-run needs no tone prop.
|
||||
expect(mountForm().find('.submit-btn').classes()).toContain('accent')
|
||||
})
|
||||
|
||||
it('renders extra controls passed by the caller', () => {
|
||||
const wrapper = mountForm({}, { default: '<label class="extra">Re-run too</label>' })
|
||||
expect(wrapper.find('.extra').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import TicketConnectorsSection from '@/components/tickets/TicketConnectorsSection.vue'
|
||||
import { mcpService } from '@/services/mcp'
|
||||
import type { MCPTool } from '@/types/mcp'
|
||||
|
||||
vi.mock('@/services/mcp', () => ({
|
||||
mcpService: {
|
||||
getOrganizationMCPTools: vi.fn(),
|
||||
createMCPTool: vi.fn(),
|
||||
updateMCPTool: vi.fn(),
|
||||
deleteMCPTool: vi.fn(),
|
||||
getMCPToolReferences: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
const CONNECTOR: MCPTool = {
|
||||
id: 7,
|
||||
name: 'Elastic prod',
|
||||
transport_type: 'http',
|
||||
enabled: true,
|
||||
url: 'https://kibana/api/agent_builder/mcp',
|
||||
usage_guidance: 'Order id is fields.order_ref.',
|
||||
organization_id: 'org-1',
|
||||
created_at: '2026-09-01T00:00:00Z',
|
||||
updated_at: '2026-09-01T00:00:00Z',
|
||||
}
|
||||
|
||||
function mountIt(connectors: MCPTool[] = []) {
|
||||
vi.mocked(mcpService.getOrganizationMCPTools).mockResolvedValue(connectors)
|
||||
return mount(TicketConnectorsSection, {
|
||||
props: { selectedIds: [] },
|
||||
global: { stubs: { 'font-awesome-icon': true } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('TicketConnectorsSection guidance', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('sends the guidance the operator wrote when creating a connector', async () => {
|
||||
vi.mocked(mcpService.createMCPTool).mockResolvedValue({ ...CONNECTOR, id: 9 })
|
||||
const wrapper = mountIt()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('.preset-tile').at(-1)!.trigger('click')
|
||||
await wrapper.find('.field-input').setValue('Elastic prod')
|
||||
await wrapper.find('.guidance-input').setValue('Order id is fields.order_ref.')
|
||||
await wrapper.find('.connect-btn').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(mcpService.createMCPTool).mock.calls[0][0]).toMatchObject({
|
||||
usage_guidance: 'Order id is fields.order_ref.',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends null rather than an empty string when no guidance is written', async () => {
|
||||
vi.mocked(mcpService.createMCPTool).mockResolvedValue({ ...CONNECTOR, id: 9 })
|
||||
const wrapper = mountIt()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('.preset-tile').at(-1)!.trigger('click')
|
||||
await wrapper.find('.field-input').setValue('Bare connector')
|
||||
await wrapper.find('.connect-btn').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(mcpService.createMCPTool).mock.calls[0][0].usage_guidance).toBeNull()
|
||||
})
|
||||
|
||||
it('never seeds guidance from a preset description', async () => {
|
||||
/**
|
||||
* A preset's `desc` is a one-line marketing subtitle ("Errors, issues and
|
||||
* traces"). Letting it become prompt text is exactly the failure this
|
||||
* field exists to fix, so it must stay empty even though `description`
|
||||
* is filled.
|
||||
*/
|
||||
const wrapper = mountIt()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('.preset-tile')[0].trigger('click')
|
||||
|
||||
expect((wrapper.find('.guidance-input').element as HTMLTextAreaElement).value).toBe('')
|
||||
expect((wrapper.find('.field-input').element as HTMLInputElement).value).toBe('Grafana')
|
||||
})
|
||||
|
||||
it('loads existing guidance into the edit form and marks the row as guided', async () => {
|
||||
const wrapper = mountIt([CONNECTOR])
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.guided-tag').exists()).toBe(true)
|
||||
|
||||
await wrapper.find('.row-btn').trigger('click')
|
||||
expect((wrapper.find('.guidance-input').element as HTMLTextAreaElement).value).toBe(
|
||||
'Order id is fields.order_ref.',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows no guided chip for an undocumented connector', async () => {
|
||||
const wrapper = mountIt([{ ...CONNECTOR, usage_guidance: null }])
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.guided-tag').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import TicketInvestigationPanel from '../../../components/tickets/TicketInvestigationPanel.vue'
|
||||
import type { InvestigationDetail, InvestigationRun } from '../../../types/ticket'
|
||||
|
||||
const baseRun: InvestigationRun = {
|
||||
id: 'r1',
|
||||
run_type: 'investigation',
|
||||
status: 'completed',
|
||||
trigger: 'manual',
|
||||
tool_calls_used: 0,
|
||||
}
|
||||
|
||||
const mountPanel = (run: Partial<InvestigationRun>, props: Record<string, unknown> = {}) =>
|
||||
mount(TicketInvestigationPanel, {
|
||||
props: {
|
||||
investigation: {
|
||||
run: { ...baseRun, ...run },
|
||||
hypotheses: [],
|
||||
events: [],
|
||||
} as InvestigationDetail,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
'font-awesome-icon': true,
|
||||
'router-link': { template: '<a><slot /></a>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe('TicketInvestigationPanel connector warning', () => {
|
||||
it('warns when the run loaded fewer connectors than configured', () => {
|
||||
const wrapper = mountPanel({
|
||||
connector_status: {
|
||||
configured: 1,
|
||||
loaded: 0,
|
||||
failed: [{ name: 'Elasticsearch', error: "[Errno 2] No such file or directory: 'npx'" }],
|
||||
},
|
||||
})
|
||||
const warning = wrapper.find('.connector-warning')
|
||||
expect(warning.exists()).toBe(true)
|
||||
expect(warning.text()).toContain('0 / 1 个已配置连接器')
|
||||
expect(warning.text()).toContain('Elasticsearch')
|
||||
expect(warning.text()).toContain('npx')
|
||||
})
|
||||
|
||||
it('stays silent when every configured connector loaded', () => {
|
||||
const wrapper = mountPanel({
|
||||
connector_status: { configured: 2, loaded: 2, failed: [] },
|
||||
})
|
||||
expect(wrapper.find('.connector-warning').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('warns when every connector loaded but the provider refused their tools', () => {
|
||||
// The #303 case: nothing "failed", so the loaded-vs-configured check alone
|
||||
// reports a clean run that gathered no evidence at all.
|
||||
const wrapper = mountPanel({
|
||||
connector_status: {
|
||||
configured: 1,
|
||||
loaded: 1,
|
||||
failed: [],
|
||||
provider_errors: ["The model provider rejected a connected tool's schema."],
|
||||
},
|
||||
})
|
||||
const warning = wrapper.find('.connector-warning')
|
||||
expect(warning.exists()).toBe(true)
|
||||
expect(warning.text()).toContain("无法使用")
|
||||
expect(warning.text()).toContain('rejected a connected tool')
|
||||
})
|
||||
|
||||
it('stays silent when the run has no connector status (older runs)', () => {
|
||||
const wrapper = mountPanel({})
|
||||
expect(wrapper.find('.connector-warning').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TicketInvestigationPanel errored tool calls', () => {
|
||||
/**
|
||||
* A connector can come up, load its tools, and then error on every query.
|
||||
* That reports loaded === configured with no provider errors, so before
|
||||
* #318 the run rendered completely clean with a confident wrong answer.
|
||||
*/
|
||||
it('warns when every tool call errored, even with all connectors loaded', () => {
|
||||
const wrapper = mountPanel({
|
||||
connector_status: {
|
||||
configured: 1,
|
||||
loaded: 1,
|
||||
failed: [],
|
||||
tool_calls: 3,
|
||||
tool_calls_failed: 3,
|
||||
},
|
||||
})
|
||||
|
||||
const text = wrapper.find('.connector-warning').text()
|
||||
expect(text).toContain('3 / 3 次工具调用出错')
|
||||
expect(text).toContain('此处结论均无证据支撑')
|
||||
})
|
||||
|
||||
it('reports a partial failure without claiming nothing was gathered', () => {
|
||||
const wrapper = mountPanel({
|
||||
connector_status: {
|
||||
configured: 1,
|
||||
loaded: 1,
|
||||
failed: [],
|
||||
tool_calls: 4,
|
||||
tool_calls_failed: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const text = wrapper.find('.connector-warning').text()
|
||||
expect(text).toContain('1 / 4 次工具调用出错')
|
||||
expect(text).not.toContain('evidence-backed')
|
||||
})
|
||||
|
||||
it('stays silent on a clean run', () => {
|
||||
const wrapper = mountPanel({
|
||||
connector_status: {
|
||||
configured: 2,
|
||||
loaded: 2,
|
||||
failed: [],
|
||||
tool_calls: 5,
|
||||
tool_calls_failed: 0,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.connector-warning').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('handles a run recorded before these counts existed', () => {
|
||||
const wrapper = mountPanel({
|
||||
connector_status: { configured: 1, loaded: 1, failed: [] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.connector-warning').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TicketInvestigationPanel guided re-run', () => {
|
||||
/**
|
||||
* Below autonomy 2 no proposal is created, so the L2 approval banner never
|
||||
* renders — and it held the only reject-with-feedback path. An L1 operator
|
||||
* had no way to correct a run short of editing the ticket description.
|
||||
*/
|
||||
it('offers a guided re-run when the user can start one', () => {
|
||||
const wrapper = mountPanel({}, { canReinvestigate: true })
|
||||
expect(wrapper.find('.rerun-btn').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('is absent for a user who cannot manage tickets', () => {
|
||||
expect(mountPanel({}, { canReinvestigate: false }).find('.panel-foot').exists()).toBe(false)
|
||||
expect(mountPanel({}).find('.panel-foot').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('disables the button with the reason a run is unavailable', () => {
|
||||
const wrapper = mountPanel({}, {
|
||||
canReinvestigate: true,
|
||||
reinvestigateBlockedReason: 'This ticket is resolved — reopen it to investigate again',
|
||||
})
|
||||
|
||||
const button = wrapper.find('.rerun-btn')
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
expect(button.attributes('title')).toContain('reopen it')
|
||||
})
|
||||
|
||||
it('emits the trimmed note and closes the form', async () => {
|
||||
const wrapper = mountPanel({}, { canReinvestigate: true })
|
||||
await wrapper.find('.rerun-btn').trigger('click')
|
||||
|
||||
await wrapper.find('.note-input').setValue(' Use fields.order_ref, not order_id. ')
|
||||
await wrapper.find('.submit-btn').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('reinvestigate')?.[0]).toEqual(['Use fields.order_ref, not order_id.'])
|
||||
// Back to the button, so a second run needs a deliberate click.
|
||||
expect(wrapper.find('.note-input').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('lets the operator back out without starting a run', async () => {
|
||||
const wrapper = mountPanel({}, { canReinvestigate: true })
|
||||
await wrapper.find('.rerun-btn').trigger('click')
|
||||
await wrapper.find('.cancel-btn').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('reinvestigate')).toBeUndefined()
|
||||
expect(wrapper.find('.rerun-btn').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('links to settings only for someone who can edit them', async () => {
|
||||
const allowed = mountPanel({}, { canReinvestigate: true, canEditSettings: true })
|
||||
await allowed.find('.rerun-btn').trigger('click')
|
||||
expect(allowed.find('.rerun-scope a').exists()).toBe(true)
|
||||
|
||||
const denied = mountPanel({}, { canReinvestigate: true, canEditSettings: false })
|
||||
await denied.find('.rerun-btn').trigger('click')
|
||||
expect(denied.find('.rerun-scope a').exists()).toBe(false)
|
||||
expect(denied.find('.rerun-scope').text()).toContain('Ticketing settings')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/composables/useUsers', () => ({
|
||||
useUsers: () => ({ users: { value: [] }, fetchUsers: vi.fn().mockResolvedValue([]) }),
|
||||
}))
|
||||
|
||||
import TicketSidePanel from '../../../components/tickets/TicketSidePanel.vue'
|
||||
import type { Ticket } from '../../../types/ticket'
|
||||
|
||||
const baseTicket = {
|
||||
id: 't1',
|
||||
ticket_number: 12,
|
||||
display_number: 'TKT-12',
|
||||
organization_id: 'org1',
|
||||
title: 'Refund never arrived',
|
||||
status: 'closed',
|
||||
priority: 'medium',
|
||||
source: 'chat_ai',
|
||||
reopened_count: 0,
|
||||
} as Ticket
|
||||
|
||||
const createWrapper = (ticket: Partial<Ticket> = {}, linkedSessionIds: string[] = ['s1']) =>
|
||||
mount(TicketSidePanel, {
|
||||
props: {
|
||||
ticket: { ...baseTicket, ...ticket } as Ticket,
|
||||
linkedSessionIds,
|
||||
canManage: false,
|
||||
},
|
||||
global: { stubs: { 'font-awesome-icon': true } },
|
||||
})
|
||||
|
||||
const csatCard = (wrapper: ReturnType<typeof createWrapper>) =>
|
||||
wrapper.findAll('.panel-card').find((card) => card.text().startsWith('CSAT'))!
|
||||
|
||||
describe('TicketSidePanel CSAT card', () => {
|
||||
it('shows the score once the customer has rated', () => {
|
||||
const card = csatCard(
|
||||
createWrapper({
|
||||
csat_requested_at: '2026-07-20T10:00:00Z',
|
||||
csat_responded_at: '2026-07-20T11:00:00Z',
|
||||
csat_score: 4,
|
||||
}),
|
||||
)
|
||||
expect(card.text()).toContain('4/5')
|
||||
expect(card.findAll('.csat-stars font-awesome-icon-stub')).toHaveLength(5)
|
||||
expect(card.text()).toContain('Rated by the customer')
|
||||
})
|
||||
|
||||
it('shows pending while the ask is out', () => {
|
||||
const card = csatCard(createWrapper({ csat_requested_at: '2026-07-20T10:00:00Z' }))
|
||||
expect(card.text()).toContain('待评价')
|
||||
expect(card.find('.csat-stars').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('goes back to pending when a reopened ticket is asked again', () => {
|
||||
const card = csatCard(
|
||||
createWrapper({
|
||||
csat_score: 2,
|
||||
csat_responded_at: '2026-07-20T11:00:00Z',
|
||||
csat_requested_at: '2026-07-21T09:00:00Z',
|
||||
}),
|
||||
)
|
||||
expect(card.text()).toContain('待评价')
|
||||
expect(card.text()).not.toContain('2/5')
|
||||
})
|
||||
|
||||
it('shows not requested before close', () => {
|
||||
const card = csatCard(createWrapper())
|
||||
expect(card.text()).toContain('未邀请评价')
|
||||
expect(card.text()).toContain('工单关闭时会自动邀请评价')
|
||||
})
|
||||
|
||||
it('explains why a ticket with no conversation is never asked', () => {
|
||||
const card = csatCard(createWrapper({}, []))
|
||||
expect(card.text()).toContain('没有可评价的关联会话')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { accountToManage, reconnectWarning } from '@/utils/channelAccounts'
|
||||
import type { ChannelAccount } from '@/services/channels'
|
||||
|
||||
const account = (overrides: Partial<ChannelAccount> = {}): ChannelAccount => ({
|
||||
id: 'a1', channel_type: 'whatsapp', external_account_id: 'PN1',
|
||||
display_name: 'Support (+1 555)', is_active: true, ...overrides,
|
||||
})
|
||||
|
||||
describe('reconnectWarning', () => {
|
||||
it('is silent when no account needs reconnecting', () => {
|
||||
expect(reconnectWarning([])).toBeUndefined()
|
||||
expect(reconnectWarning([account(), account({ needs_reconnect: false })])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('names the account that needs reconnecting', () => {
|
||||
expect(reconnectWarning([account({ needs_reconnect: true })]))
|
||||
.toBe('以下账号的访问权限已过期:Support (+1 555),请重新连接以恢复回复。')
|
||||
})
|
||||
|
||||
it('names only the expired ones when several are connected', () => {
|
||||
const warning = reconnectWarning([
|
||||
account({ id: 'a1', display_name: 'Sales', needs_reconnect: true }),
|
||||
account({ id: 'a2', display_name: 'Healthy' }),
|
||||
account({ id: 'a3', display_name: 'Billing', needs_reconnect: true }),
|
||||
])
|
||||
expect(warning).toBe('以下账号的访问权限已过期:Sales, Billing,请重新连接以恢复回复。')
|
||||
})
|
||||
|
||||
it('falls back to the platform id when the account has no name', () => {
|
||||
expect(reconnectWarning([account({ display_name: undefined, needs_reconnect: true })]))
|
||||
.toContain('以下账号的访问权限已过期:PN1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('accountToManage', () => {
|
||||
it('opens the account that needs reconnecting, even when it is not first', () => {
|
||||
const healthy = account({ id: 'a1' })
|
||||
const expired = account({ id: 'a2', needs_reconnect: true })
|
||||
expect(accountToManage([healthy, expired])).toBe(expired)
|
||||
})
|
||||
|
||||
it('opens the first expired one when several need reconnecting', () => {
|
||||
const first = account({ id: 'a2', needs_reconnect: true })
|
||||
expect(accountToManage([account({ id: 'a1' }), first, account({ id: 'a3', needs_reconnect: true })])).toBe(first)
|
||||
})
|
||||
|
||||
it('opens the first account when every one is healthy', () => {
|
||||
const first = account({ id: 'a1' })
|
||||
expect(accountToManage([first, account({ id: 'a2' })])).toBe(first)
|
||||
})
|
||||
|
||||
it('opens nothing when no account is connected', () => {
|
||||
expect(accountToManage([])).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import {
|
||||
parseSignupMessage,
|
||||
signupLoginOptions,
|
||||
runInstagramLogin,
|
||||
runBusinessLogin,
|
||||
} from '../../utils/metaSdk'
|
||||
|
||||
const message = (data: unknown, origin = 'https://www.facebook.com') =>
|
||||
({ origin, data: typeof data === 'string' ? data : JSON.stringify(data) }) as MessageEvent
|
||||
|
||||
const FINISH = {
|
||||
type: 'WA_EMBEDDED_SIGNUP',
|
||||
event: 'FINISH',
|
||||
data: { waba_id: 'WABA9', phone_number_id: 'PN555' },
|
||||
}
|
||||
|
||||
describe('parseSignupMessage', () => {
|
||||
it('reads the WABA and phone number from a finished signup', () => {
|
||||
expect(parseSignupMessage(message(FINISH))).toEqual({
|
||||
waba_id: 'WABA9',
|
||||
phone_number_id: 'PN555',
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts the other Facebook origins the flow can post from', () => {
|
||||
for (const origin of ['https://web.facebook.com', 'https://business.facebook.com']) {
|
||||
expect(parseSignupMessage(message(FINISH, origin))).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a lookalike origin', () => {
|
||||
// Meta's own sample uses origin.endsWith('facebook.com'), which these pass.
|
||||
for (const origin of ['https://evil-facebook.com', 'https://facebook.com.attacker.io']) {
|
||||
expect(parseSignupMessage(message(FINISH, origin))).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a plain http facebook origin', () => {
|
||||
expect(parseSignupMessage(message(FINISH, 'http://www.facebook.com'))).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores unrelated messages on a trusted origin', () => {
|
||||
expect(parseSignupMessage(message({ type: 'SOMETHING_ELSE' }))).toBeNull()
|
||||
expect(parseSignupMessage(message('not json at all'))).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a signup that was abandoned rather than finished', () => {
|
||||
expect(parseSignupMessage(message({ ...FINISH, event: 'CANCEL' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a finish missing either id', () => {
|
||||
expect(parseSignupMessage(message({ ...FINISH, data: { waba_id: 'WABA9' } }))).toBeNull()
|
||||
expect(parseSignupMessage(message({ ...FINISH, data: {} }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('signupLoginOptions', () => {
|
||||
it('asks for a code rather than the default token response', () => {
|
||||
expect(signupLoginOptions('CFG1')).toEqual({
|
||||
config_id: 'CFG1',
|
||||
response_type: 'code',
|
||||
override_default_response_type: true,
|
||||
extras: { setup: {} },
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the WhatsApp setup extra for WhatsApp', () => {
|
||||
expect(signupLoginOptions('CFG1', 'whatsapp').extras).toEqual({ setup: {} })
|
||||
})
|
||||
|
||||
it('omits the setup extra for Facebook Login for Business', () => {
|
||||
// extras.setup opens Embedded Signup; Messenger/Instagram must not send it.
|
||||
expect(signupLoginOptions('CFG1', 'messenger')).not.toHaveProperty('extras')
|
||||
expect(signupLoginOptions('CFG1', 'instagram')).not.toHaveProperty('extras')
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuth login popups', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
/** Capture the URL the popup is opened with, without completing the login. */
|
||||
const capturePopupUrl = (run: () => Promise<unknown>) => {
|
||||
let opened = ''
|
||||
vi.stubGlobal('open', (url: string) => {
|
||||
opened = url
|
||||
return { closed: false, close: () => {} }
|
||||
})
|
||||
// The promise stays pending (no callback message); we only want the URL.
|
||||
void run().catch(() => {})
|
||||
return new URL(opened)
|
||||
}
|
||||
|
||||
it('sends Instagram to its own authorize endpoint, not a Facebook dialog', () => {
|
||||
const url = capturePopupUrl(() =>
|
||||
runInstagramLogin({ appId: 'IGAPP', redirectUri: 'https://app.test/cb.html' }))
|
||||
|
||||
expect(url.origin + url.pathname).toBe('https://www.instagram.com/oauth/authorize')
|
||||
expect(url.searchParams.get('client_id')).toBe('IGAPP')
|
||||
expect(url.searchParams.get('response_type')).toBe('code')
|
||||
expect(url.searchParams.get('redirect_uri')).toBe('https://app.test/cb.html')
|
||||
expect(url.searchParams.get('force_reauth')).toBe('true')
|
||||
expect(url.searchParams.get('scope')).toContain('instagram_business_manage_messages')
|
||||
// Least privilege: we only read the account and handle DMs, so no
|
||||
// comments/publishing/insights scopes to enlarge App Review.
|
||||
expect(url.searchParams.get('scope')).not.toContain('content_publish')
|
||||
expect(url.searchParams.get('state')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('sends Messenger to the Facebook dialog with its configuration', () => {
|
||||
const url = capturePopupUrl(() =>
|
||||
runBusinessLogin({
|
||||
appId: 'FBAPP', configId: 'CFG1', graphVersion: 'v21.0',
|
||||
redirectUri: 'https://app.test/cb.html',
|
||||
}))
|
||||
|
||||
expect(url.hostname).toBe('www.facebook.com')
|
||||
expect(url.searchParams.get('config_id')).toBe('CFG1')
|
||||
expect(url.searchParams.get('client_id')).toBe('FBAPP')
|
||||
})
|
||||
|
||||
it('rejects when the browser blocks the popup', async () => {
|
||||
vi.stubGlobal('open', () => null)
|
||||
await expect(
|
||||
runInstagramLogin({ appId: 'IGAPP', redirectUri: 'https://app.test/cb.html' }),
|
||||
).rejects.toThrow(/弹窗被拦截/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,528 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FaqItem } from '@/types/faq'
|
||||
import FaqSeoFields from './FaqSeoFields.vue'
|
||||
import MarkdownEditor from './MarkdownEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
faq: FaqItem
|
||||
editing: boolean
|
||||
isNew?: boolean
|
||||
saving?: boolean
|
||||
draftQuestion: string
|
||||
draftAnswer: string
|
||||
/** Per-article SEO drafts. Optional so callers that don't expose the SEO
|
||||
* section (and tests) can leave them out — they default to empty. */
|
||||
draftSlug?: string
|
||||
draftUrlPath?: string
|
||||
draftMetaTitle?: string
|
||||
draftMetaDescription?: string
|
||||
/** Topic (category) draft plus the org's existing topics for autocomplete.
|
||||
* Optional for the same reason as the SEO drafts. */
|
||||
draftCategory?: string
|
||||
categories?: string[]
|
||||
locked?: boolean
|
||||
/** Selection mode is on somewhere in the list (keeps checkboxes visible). */
|
||||
selectable?: boolean
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'toggle-status': []
|
||||
edit: []
|
||||
delete: []
|
||||
save: []
|
||||
cancel: []
|
||||
'toggle-select': []
|
||||
'update:draftQuestion': [value: string]
|
||||
'update:draftAnswer': [value: string]
|
||||
'update:draftCategory': [value: string]
|
||||
'update:draftSlug': [value: string]
|
||||
'update:draftUrlPath': [value: string]
|
||||
'update:draftMetaTitle': [value: string]
|
||||
'update:draftMetaDescription': [value: string]
|
||||
}>()
|
||||
|
||||
function onQuestionInput(event: Event) {
|
||||
emit('update:draftQuestion', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function onCategoryInput(event: Event) {
|
||||
emit('update:draftCategory', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
const sourceLabel = () => props.faq.source_label || '自动生成'
|
||||
|
||||
// Mirrors the category column width in app/models/faq.py — the API 422s
|
||||
// anything longer, so this only stops the user typing past the limit.
|
||||
const MAX_TOPIC = 100
|
||||
|
||||
// Answers are stored as Markdown; strip the syntax and cap the length for the
|
||||
// compact, line-clamped card preview (the public article page and the editor
|
||||
// show the full formatted content).
|
||||
const PREVIEW_MAX_CHARS = 240
|
||||
function answerPreview(md: string): string {
|
||||
const text = (md || '')
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[#>*_`~]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return text.length > PREVIEW_MAX_CHARS ? `${text.slice(0, PREVIEW_MAX_CHARS).trimEnd()}…` : text
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="faq-card" :class="{ 'faq-card--editing': editing, 'faq-card--selecting': selectable, 'faq-card--selected': selected }">
|
||||
<!-- Edit mode -->
|
||||
<div v-if="editing">
|
||||
<div class="edit-label">{{ isNew ? '新建常见问题' : '编辑中' }}</div>
|
||||
<input
|
||||
class="edit-question"
|
||||
type="text"
|
||||
placeholder="问题"
|
||||
:value="draftQuestion"
|
||||
@input="onQuestionInput"
|
||||
/>
|
||||
<MarkdownEditor
|
||||
class="edit-answer"
|
||||
:model-value="draftAnswer"
|
||||
@update:model-value="$emit('update:draftAnswer', $event)"
|
||||
/>
|
||||
<p class="edit-hint">支持 Markdown——标题、<strong>加粗</strong>、列表、链接和图片。</p>
|
||||
<label class="edit-topic">
|
||||
<span class="edit-topic__label">分类</span>
|
||||
<input
|
||||
type="text"
|
||||
class="edit-topic__input"
|
||||
list="faq-topic-options"
|
||||
:maxlength="MAX_TOPIC"
|
||||
:placeholder="isNew ? 'General' : '留空则保留当前分类'"
|
||||
:value="draftCategory"
|
||||
@input="onCategoryInput"
|
||||
/>
|
||||
<datalist id="faq-topic-options">
|
||||
<option v-for="c in categories" :key="c" :value="c" />
|
||||
</datalist>
|
||||
<span class="edit-topic__hint">选择已有分类,或输入新分类以创建。</span>
|
||||
</label>
|
||||
<FaqSeoFields
|
||||
:slug="draftSlug"
|
||||
:url-path="draftUrlPath"
|
||||
:meta-title="draftMetaTitle"
|
||||
:meta-description="draftMetaDescription"
|
||||
:question="draftQuestion"
|
||||
@update:slug="$emit('update:draftSlug', $event)"
|
||||
@update:url-path="$emit('update:draftUrlPath', $event)"
|
||||
@update:meta-title="$emit('update:draftMetaTitle', $event)"
|
||||
@update:meta-description="$emit('update:draftMetaDescription', $event)"
|
||||
/>
|
||||
<div class="edit-actions">
|
||||
<button class="btn-save" type="button" :disabled="saving" @click="$emit('save')">
|
||||
{{ saving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
<button class="btn-cancel" type="button" :disabled="saving" @click="$emit('cancel')">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display mode -->
|
||||
<div v-else class="faq-card__row">
|
||||
<input
|
||||
v-if="!locked"
|
||||
class="faq-card__check"
|
||||
type="checkbox"
|
||||
:checked="selected"
|
||||
:aria-label="`选择 ${faq.question}`"
|
||||
@change="$emit('toggle-select')"
|
||||
/>
|
||||
<div class="faq-card__body">
|
||||
<div class="faq-card__question">{{ faq.question }}</div>
|
||||
<div class="faq-card__answer">{{ answerPreview(faq.answer) }}</div>
|
||||
<div class="faq-card__source">
|
||||
<span class="faq-card__source-dot"></span>
|
||||
{{ sourceLabel() }}
|
||||
<span
|
||||
v-if="(faq.helpful_yes || 0) + (faq.helpful_no || 0) > 0"
|
||||
class="faq-card__fb"
|
||||
:title="`${faq.helpful_yes || 0} 人觉得有帮助,${faq.helpful_no || 0} 人觉得没帮助`"
|
||||
>
|
||||
<span class="faq-card__fb-item faq-card__fb-item--up">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M7 11v9H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z" /><path d="M7 11l4-8a2 2 0 0 1 3 1.8V9h4.5a2 2 0 0 1 2 2.4l-1.4 7A2 2 0 0 1 17 20H7" /></svg>
|
||||
{{ faq.helpful_yes || 0 }}
|
||||
</span>
|
||||
<span class="faq-card__fb-item faq-card__fb-item--down">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M17 13V4h3a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1z" /><path d="M17 13l-4 8a2 2 0 0 1-3-1.8V15H5.5a2 2 0 0 1-2-2.4l1.4-7A2 2 0 0 1 7 4h10" /></svg>
|
||||
{{ faq.helpful_no || 0 }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="faq-card__controls">
|
||||
<button
|
||||
class="pill"
|
||||
:class="faq.status === 'published' ? 'pill--published' : 'pill--draft'"
|
||||
type="button"
|
||||
:disabled="locked"
|
||||
@click="$emit('toggle-status')"
|
||||
>
|
||||
<span class="pill__dot"></span>
|
||||
{{ faq.status === 'published' ? '已发布' : '草稿' }}
|
||||
</button>
|
||||
<button v-if="!locked" class="icon-btn icon-btn--edit" type="button" title="编辑" @click="$emit('edit')">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" /></svg>
|
||||
</button>
|
||||
<button v-if="!locked" class="icon-btn icon-btn--delete" type="button" title="删除" @click="$emit('delete')">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" /><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.faq-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.faq-card--editing {
|
||||
border-color: var(--purple-border);
|
||||
}
|
||||
|
||||
.faq-card--selected {
|
||||
border-color: var(--teal-border);
|
||||
background: var(--teal-bg);
|
||||
}
|
||||
|
||||
.faq-card__check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 3px 0 0;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--c-teal);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.faq-card:hover .faq-card__check,
|
||||
.faq-card--selecting .faq-card__check,
|
||||
.faq-card__check:checked,
|
||||
.faq-card__check:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.faq-card__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.faq-card__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.faq-card__question {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.faq-card__answer {
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
/* Keep every card compact and uniform — imported articles can be very long;
|
||||
full content is on the public page and in the editor. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.faq-card__source {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-top: 11px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.faq-card__source-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--faint);
|
||||
}
|
||||
|
||||
.faq-card__fb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding-left: 9px;
|
||||
margin-left: 2px;
|
||||
border-left: 1px solid var(--o10);
|
||||
}
|
||||
|
||||
.faq-card__fb-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.faq-card__fb-item--up {
|
||||
color: var(--c-teal, #1d9e75);
|
||||
}
|
||||
|
||||
.faq-card__fb-item--down {
|
||||
color: var(--c-coral, #d85a30);
|
||||
}
|
||||
|
||||
.faq-card__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 13px;
|
||||
border-radius: 9px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pill:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pill__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.pill--published {
|
||||
background: var(--teal-bg);
|
||||
border: 1px solid var(--teal-border);
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.pill--published .pill__dot {
|
||||
background: var(--c-teal);
|
||||
}
|
||||
|
||||
.pill--draft {
|
||||
background: var(--pill-idle-bg);
|
||||
border: 1px solid var(--o12);
|
||||
color: var(--pill-idle-fg);
|
||||
}
|
||||
|
||||
.pill--draft .pill__dot {
|
||||
background: var(--faint);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.icon-btn--edit:hover {
|
||||
background: var(--o08);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.icon-btn--delete:hover {
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
border-color: var(--coral-border);
|
||||
}
|
||||
|
||||
.edit-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--c-purple);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.edit-question {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 10px;
|
||||
padding: 11px 13px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.edit-topic {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.edit-topic__label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.edit-topic__input {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
display: block;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 10px;
|
||||
padding: 9px 12px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.edit-topic__hint {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
/* .edit-answer wraps the MarkdownEditor, which supplies its own chrome. */
|
||||
.edit-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.edit-hint strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
padding: 9px 18px;
|
||||
background: var(--accent-solid);
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
color: var(--on-accent-solid);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 9px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 9px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Mobile ──────────────────────────────────────────────────────────────
|
||||
The status pill plus two icon buttons took ~200px of a 375px row, leaving
|
||||
the question a column so narrow it wrapped a word per line. Below 768px
|
||||
the controls drop to their own line and the question gets the full width. */
|
||||
@media (max-width: 768px) {
|
||||
.faq-card__row {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.faq-card__body {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.faq-card__controls {
|
||||
flex: 1 0 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.faq-card__question {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 44px minimum touch target (Apple HIG / WCAG 2.5.8) */
|
||||
.icon-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-save,
|
||||
.btn-cancel {
|
||||
flex: 1 1 auto;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
category: string
|
||||
count: number
|
||||
/** Published FAQs in this group (for the header stat). */
|
||||
publishedCount?: number
|
||||
/** Cards in this group currently selected; undefined hides the select-all. */
|
||||
selectedCount?: number
|
||||
/** Whether the group body is expanded. */
|
||||
open?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-all': [on: boolean]
|
||||
toggle: []
|
||||
}>()
|
||||
|
||||
const allSelected = () => props.selectedCount === props.count && props.count > 0
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="category-group">
|
||||
<div class="category-group__header">
|
||||
<input
|
||||
v-if="selectedCount !== undefined"
|
||||
class="category-group__check"
|
||||
type="checkbox"
|
||||
:checked="allSelected()"
|
||||
:indeterminate.prop="!!selectedCount && !allSelected()"
|
||||
:aria-label="`全选 ${category}`"
|
||||
@click.stop
|
||||
@change="$emit('toggle-all', !allSelected())"
|
||||
/>
|
||||
<button
|
||||
class="category-group__toggle"
|
||||
type="button"
|
||||
:aria-expanded="open"
|
||||
@click="$emit('toggle')"
|
||||
>
|
||||
<svg
|
||||
class="category-group__chevron"
|
||||
:class="{ 'category-group__chevron--open': open }"
|
||||
viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
|
||||
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"
|
||||
><path d="M9 6l6 6-6 6" /></svg>
|
||||
<span class="category-group__name">{{ category.toUpperCase() }}</span>
|
||||
<span class="category-group__count">
|
||||
{{ count }} 条回答<template v-if="publishedCount !== undefined"> · {{ publishedCount }} 条已发布</template>
|
||||
</span>
|
||||
</button>
|
||||
<div class="category-group__rule"></div>
|
||||
</div>
|
||||
<div v-show="open" class="category-group__cards">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.category-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.category-group__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.category-group__check {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
accent-color: var(--c-teal);
|
||||
cursor: pointer;
|
||||
opacity: 0.55;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.category-group__header:hover .category-group__check,
|
||||
.category-group__check:checked,
|
||||
.category-group__check:indeterminate {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.category-group__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 2px 0;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.category-group__chevron {
|
||||
color: var(--muted2);
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.category-group__chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.category-group__name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.category-group__count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.category-group__rule {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--o07);
|
||||
}
|
||||
|
||||
.category-group__cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ── Mobile ─────────────────────────────────────────────────────────────
|
||||
The decorative rule took flex:1 of the header, squeezing the toggle until
|
||||
the name broke mid-phrase ("ACCOUNT &" / "SECURITY").
|
||||
|
||||
Grid rather than flex-wrap: the chevron owns column 1 and the text owns
|
||||
column 2, so the count sits under the name and the chevron can never be
|
||||
pushed onto a line of its own however narrow the screen or long the
|
||||
category name. */
|
||||
@media (max-width: 768px) {
|
||||
.category-group__rule {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.category-group__toggle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
column-gap: 8px;
|
||||
row-gap: 2px;
|
||||
text-align: left;
|
||||
/* 44px minimum touch target (Apple HIG / WCAG 2.5.8) */
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.category-group__chevron {
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.category-group__name,
|
||||
.category-group__count {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import FaqOrb from './FaqOrb.vue'
|
||||
|
||||
defineProps<{
|
||||
sourceCount: number
|
||||
pageCount: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
generate: []
|
||||
import: []
|
||||
add: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<FaqOrb class="empty-state__orb" :size="64" />
|
||||
<h3 class="empty-state__title">还没有常见问题</h3>
|
||||
<p class="empty-state__copy">
|
||||
让 ReplyPier 阅读你的知识库,起草清晰、可直接发布的常见问题——按分类整理,并随文档更新保持同步。
|
||||
</p>
|
||||
<div class="empty-state__pill">
|
||||
<span class="empty-state__dot"></span>
|
||||
读取 {{ sourceCount }} 个来源 · {{ pageCount }} 个页面
|
||||
</div>
|
||||
<div class="empty-state__actions">
|
||||
<button class="btn btn--primary" type="button" @click="$emit('generate')">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3v4M3 5h4M6 17v4M4 19h4M13 3l2.5 6.5L22 12l-6.5 2.5L13 21l-2.5-6.5L4 12l6.5-2.5z" /></svg>
|
||||
从知识库生成
|
||||
</button>
|
||||
<button class="btn btn--ghost" type="button" @click="$emit('import')">导入已有常见问题</button>
|
||||
<button class="btn btn--text" type="button" @click="$emit('add')">手动添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--o14);
|
||||
border-radius: 16px;
|
||||
padding: 56px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state__orb {
|
||||
margin: 0 auto 22px;
|
||||
}
|
||||
|
||||
.empty-state__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.empty-state__copy {
|
||||
font-size: 14.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
margin: 0 auto 26px;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.empty-state__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--o04);
|
||||
border: 1px solid var(--o10);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.empty-state__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--c-teal);
|
||||
box-shadow: 0 0 6px var(--c-teal);
|
||||
}
|
||||
|
||||
.empty-state__actions {
|
||||
display: flex;
|
||||
gap: 11px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border-radius: 12px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: filter var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
padding: 14px 24px;
|
||||
background: var(--grad-generate);
|
||||
border: none;
|
||||
color: var(--on-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn--primary:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
padding: 14px 20px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
color: var(--text2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn--ghost:hover {
|
||||
background: var(--o08);
|
||||
}
|
||||
|
||||
.btn--text {
|
||||
padding: 14px 20px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--o12);
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn--text:hover {
|
||||
background: var(--o04);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import FaqOrb from './FaqOrb.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
phase: 'idle' | 'generating' | 'ready'
|
||||
sourceCount: number
|
||||
pageCount: number
|
||||
faqCount: number
|
||||
publishedCount: number
|
||||
/** Sources without FAQs yet (from the estimate); null = unknown. */
|
||||
newSourceCount?: number | null
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
generate: []
|
||||
import: []
|
||||
add: []
|
||||
}>()
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.phase === 'generating') return '正在生成常见问题…'
|
||||
if (props.phase === 'ready') return '常见问题待发布'
|
||||
return '生成常见问题'
|
||||
})
|
||||
|
||||
const subtitle = computed(() =>
|
||||
props.phase === 'ready'
|
||||
? `${props.faqCount} 条常见问题 · ${props.publishedCount} 条已发布`
|
||||
: `读取 ${props.sourceCount} 个来源 · ${props.pageCount} 个页面`,
|
||||
)
|
||||
|
||||
const generateLabel = computed(() => {
|
||||
if (props.phase === 'generating') return '生成中…'
|
||||
if (props.phase === 'ready') {
|
||||
// Regenerate only reads new (ungenerated) sources — say so.
|
||||
if (typeof props.newSourceCount === 'number' && props.newSourceCount > 0) {
|
||||
return `生成 ${props.newSourceCount} 个新来源`
|
||||
}
|
||||
return '重新生成'
|
||||
}
|
||||
return '生成'
|
||||
})
|
||||
|
||||
const noNewSources = computed(() => props.phase === 'ready' && props.newSourceCount === 0)
|
||||
|
||||
const generateDisabled = computed(() => props.phase === 'generating' || props.disabled || noNewSources.value)
|
||||
|
||||
const generateTitle = computed(() =>
|
||||
noNewSources.value ? '所有知识来源都已生成常见问题' : undefined,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="generate-bar">
|
||||
<div class="generate-bar__lead">
|
||||
<FaqOrb :size="40" />
|
||||
<div>
|
||||
<div class="generate-bar__title">{{ title }}</div>
|
||||
<div class="generate-bar__sub">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5.5A2 2 0 0 1 6 4h5v16H6a2 2 0 0 0-2 1.5z" /><path d="M20 5.5A2 2 0 0 0 18 4h-5v16h5a2 2 0 0 1 2 1.5z" /></svg>
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="generate-bar__actions">
|
||||
<button class="btn btn--ghost" type="button" @click="$emit('import')">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /><path d="M8 11l4 4 4-4" /><path d="M5 21h14" /></svg>
|
||||
导入
|
||||
</button>
|
||||
<button class="btn btn--ghost" type="button" @click="$emit('add')">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
添加常见问题
|
||||
</button>
|
||||
<button class="btn btn--generate" type="button" :disabled="generateDisabled" :title="generateTitle" @click="$emit('generate')">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3v4M3 5h4M6 17v4M4 19h4M13 3l2.5 6.5L22 12l-6.5 2.5L13 21l-2.5-6.5L4 12l6.5-2.5z" /></svg>
|
||||
{{ generateLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.generate-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 22px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.generate-bar__lead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.generate-bar__title {
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.generate-bar__sub {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.generate-bar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 11px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.btn--ghost:hover {
|
||||
background: var(--o08);
|
||||
}
|
||||
|
||||
.btn--generate {
|
||||
padding: 12px 20px;
|
||||
background: var(--purple-bg);
|
||||
border: 1px solid var(--purple-border);
|
||||
color: var(--c-purple);
|
||||
}
|
||||
|
||||
.btn--generate:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Mobile ─────────────────────────────────────────────────────────────
|
||||
Three actions wrapped 2 + 1, leaving a stranded Regenerate button. Below
|
||||
768px they share one row as equal thirds. */
|
||||
@media (max-width: 768px) {
|
||||
.generate-bar {
|
||||
padding: 14px 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.generate-bar__actions {
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
padding: 12px 8px;
|
||||
/* 44px minimum touch target (Apple HIG / WCAG 2.5.8) */
|
||||
min-height: 44px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Labels alone would overflow three-up on the narrowest phones */
|
||||
@media (max-width: 400px) {
|
||||
.btn {
|
||||
font-size: 12.5px;
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { FaqGenerationJob, FaqJobStage } from '@/types/faq'
|
||||
import FaqOrb from './FaqOrb.vue'
|
||||
|
||||
const props = defineProps<{ job: FaqGenerationJob }>()
|
||||
|
||||
const STAGE_ORDER: FaqJobStage[] = ['analyzing_sources', 'extracting', 'drafting', 'grouping']
|
||||
const STAGE_LABELS = [
|
||||
'分析你的来源',
|
||||
'从内容中提取问题',
|
||||
'撰写有依据的回答',
|
||||
'按分类整理',
|
||||
]
|
||||
|
||||
const steps = computed(() => {
|
||||
const stage = props.job.stage
|
||||
// Before the first stage report, the first step is active; when the job
|
||||
// finishes, everything is done.
|
||||
const activeIndex = stage === 'completed' ? STAGE_ORDER.length : Math.max(0, STAGE_ORDER.indexOf(stage))
|
||||
return STAGE_LABELS.map((label, index) => ({
|
||||
label,
|
||||
state: index < activeIndex ? 'done' : index === activeIndex ? 'active' : 'todo',
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="progress-card">
|
||||
<div class="progress-card__head">
|
||||
<FaqOrb :size="48" />
|
||||
<div>
|
||||
<div class="progress-card__title">正在读取你的知识库…</div>
|
||||
<div class="progress-card__note">
|
||||
通常不到一分钟即可完成。你可以离开此页面,我们会在后台继续处理。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-track__sweep"></div>
|
||||
</div>
|
||||
<div class="steps">
|
||||
<div v-for="step in steps" :key="step.label" class="step" :class="step.state">
|
||||
<span class="step__icon">
|
||||
<svg v-if="step.state === 'done'" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--on-accent-solid)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4 4 10-10" /></svg>
|
||||
<span v-else-if="step.state === 'active'" class="step__spinner"></span>
|
||||
<span v-else class="step__dot"></span>
|
||||
</span>
|
||||
<span class="step__label">{{ step.label }}</span>
|
||||
<span v-if="step.state === 'active'" class="step__pulse">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progress-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 16px;
|
||||
padding: 40px 32px;
|
||||
}
|
||||
|
||||
.progress-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.progress-card__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.progress-card__note {
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--o08);
|
||||
overflow: hidden;
|
||||
margin-bottom: 26px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-track__sweep {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 38%;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--grad-generate);
|
||||
animation: faq-progress 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes faq-progress {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(320%); }
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
.step__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.step.done .step__icon {
|
||||
background: var(--accent-solid);
|
||||
}
|
||||
|
||||
.step__spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--c-purple);
|
||||
border-top-color: transparent;
|
||||
animation: faq-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes faq-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.step__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--faint);
|
||||
}
|
||||
|
||||
.step__label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.step.todo .step__label {
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.step__pulse {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.step__pulse span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--c-purple);
|
||||
animation: faq-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.step__pulse span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.step__pulse span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes faq-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,471 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import FaqOrb from './FaqOrb.vue'
|
||||
import type { FaqImportMode } from '@/types/faq'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
submitting?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [url: string, mode: FaqImportMode, preserveUrls: boolean]
|
||||
'submit-pdf': [file: File]
|
||||
}>()
|
||||
|
||||
const url = ref('')
|
||||
const mode = ref<FaqImportMode>('qa')
|
||||
const pdfFile = ref<File | null>(null)
|
||||
// Article mode only: keep every imported article at the URL it already has.
|
||||
const preserveUrls = ref(true)
|
||||
|
||||
const MODES: { value: FaqImportMode; title: string; description: string }[] = [
|
||||
{
|
||||
value: 'qa',
|
||||
title: '问答页面',
|
||||
description: 'AI 读取单个页面并提取每一组问答,会消耗 AI 额度。',
|
||||
},
|
||||
{
|
||||
value: 'articles',
|
||||
title: '文章页面',
|
||||
description: '按原样导入该页面链接到的所有文章——文本、图片和链接,不消耗 AI。',
|
||||
},
|
||||
{
|
||||
value: 'pdf',
|
||||
title: 'PDF 文档',
|
||||
description: 'AI 从上传的 PDF 中提取问答,会消耗 AI 额度。',
|
||||
},
|
||||
]
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) {
|
||||
url.value = ''
|
||||
mode.value = 'qa'
|
||||
pdfFile.value = null
|
||||
preserveUrls.value = true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// The option only exists for article mode; reset it on every mode change so
|
||||
// switching away and back can't carry a stale choice into the next import.
|
||||
watch(mode, () => {
|
||||
preserveUrls.value = true
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (props.submitting) return false
|
||||
if (mode.value === 'pdf') return pdfFile.value !== null
|
||||
return url.value.trim().length > 0
|
||||
})
|
||||
|
||||
const hint = computed(() => {
|
||||
if (mode.value === 'articles')
|
||||
return 'ReplyPier 会跟随该页面上的文章链接,按原文将每篇文章导入为草稿——包含格式和图片。'
|
||||
if (mode.value === 'pdf')
|
||||
return 'ReplyPier 会读取 PDF,提取每组问答并作为草稿添加到这里,供你审核后发布。'
|
||||
return 'ReplyPier 会抓取该页面,提取每组问答并作为草稿添加到这里,供你审核后发布。'
|
||||
})
|
||||
|
||||
const fileSize = computed(() => {
|
||||
const bytes = pdfFile.value?.size ?? 0
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
})
|
||||
|
||||
const dragOver = ref(false)
|
||||
|
||||
function setPdf(file: File | null | undefined) {
|
||||
if (file && file.type === 'application/pdf') pdfFile.value = file
|
||||
else if (file) toast.error('请选择 PDF 文件')
|
||||
}
|
||||
|
||||
function onPdfChange(event: Event) {
|
||||
setPdf((event.target as HTMLInputElement).files?.[0] ?? null)
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragOver.value = false
|
||||
setPdf(event.dataTransfer?.files?.[0])
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!canSubmit.value) return
|
||||
if (mode.value === 'pdf') {
|
||||
if (pdfFile.value) emit('submit-pdf', pdfFile.value)
|
||||
return
|
||||
}
|
||||
const cleaned = url.value.trim().replace(/^https?:\/\//i, '')
|
||||
emit('submit', `https://${cleaned}`, mode.value, mode.value === 'articles' && preserveUrls.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-if="open" @close="$emit('close')">
|
||||
<template #title>迁移现有帮助中心</template>
|
||||
<template #content>
|
||||
<p class="import-sub">粘贴帮助中心或常见问题页面的 URL,并选择导入方式。</p>
|
||||
|
||||
<div class="mode-cards" role="radiogroup" aria-label="导入方式">
|
||||
<label
|
||||
v-for="option in MODES"
|
||||
:key="option.value"
|
||||
class="mode-card"
|
||||
:class="{ 'mode-card--active': mode === option.value }"
|
||||
>
|
||||
<input v-model="mode" class="mode-card__radio" type="radio" name="faq-import-mode" :value="option.value" />
|
||||
<span class="mode-card__title">{{ option.title }}</span>
|
||||
<span class="mode-card__desc">{{ option.description }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="mode !== 'pdf'">
|
||||
<label class="import-label" for="faq-import-url">{{ mode === 'articles' ? '帮助中心索引 URL' : '常见问题页面 URL' }}</label>
|
||||
<div class="import-input">
|
||||
<span class="import-input__prefix">https://</span>
|
||||
<input
|
||||
id="faq-import-url"
|
||||
v-model="url"
|
||||
type="text"
|
||||
placeholder="support.yourcompany.com/faq"
|
||||
@keydown.enter="submit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="import-label" for="faq-import-pdf">PDF 文件(最大 25MB)</label>
|
||||
<label
|
||||
class="pdf-drop"
|
||||
:class="{ 'pdf-drop--filled': pdfFile, 'pdf-drop--drag': dragOver }"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave.prevent="dragOver = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<input id="faq-import-pdf" class="pdf-drop__input" type="file" accept="application/pdf" @change="onPdfChange" />
|
||||
<span class="pdf-drop__icon">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z" /><path d="M9 13h6M9 17h4" /></svg>
|
||||
</span>
|
||||
<span class="pdf-drop__text">
|
||||
<template v-if="pdfFile">
|
||||
<span class="pdf-drop__name">{{ pdfFile.name }}</span>
|
||||
<span class="pdf-drop__meta">{{ fileSize }} · 重新选择文件</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="pdf-drop__name">选择 PDF 或拖放到此处</span>
|
||||
<span class="pdf-drop__meta">仅支持 PDF,最大 25 MB</span>
|
||||
</template>
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
<label v-if="mode === 'articles'" class="preserve">
|
||||
<input v-model="preserveUrls" class="preserve__box" type="checkbox" />
|
||||
<span class="preserve__text">
|
||||
<span class="preserve__title">保留文章原始 URL</span>
|
||||
<span class="preserve__desc">
|
||||
每篇文章仍保留现有的 URL 路径,因此搜索排名和已有链接在迁移后依然有效。将旧域名指向这里,访客不会察觉任何变化。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="import-hint">
|
||||
<FaqOrb :size="34" />
|
||||
<div>{{ hint }}</div>
|
||||
</div>
|
||||
<div class="import-actions">
|
||||
<button class="btn-cancel" type="button" @click="$emit('close')">取消</button>
|
||||
<button class="btn-import" type="button" :disabled="!canSubmit" @click="submit">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /><path d="M8 11l4 4 4-4" /><path d="M5 21h14" /></svg>
|
||||
{{ submitting ? '导入中…' : mode === 'articles' ? '导入文章' : mode === 'pdf' ? '导入 PDF' : '导入常见问题' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.import-sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: -12px 0 18px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mode-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.mode-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 13px 14px;
|
||||
background: var(--o03);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.mode-card--active {
|
||||
background: var(--purple-bg);
|
||||
border-color: var(--purple-border);
|
||||
}
|
||||
|
||||
.mode-card__radio {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mode-card__title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.mode-card--active .mode-card__title {
|
||||
color: var(--c-purple);
|
||||
}
|
||||
|
||||
.mode-card__desc {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.import-label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.import-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 11px;
|
||||
padding: 0 14px;
|
||||
margin-bottom: 16px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.import-input__prefix {
|
||||
color: var(--faint);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.import-input input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
font-size: 13.5px;
|
||||
padding: 13px 4px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.pdf-drop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--bg);
|
||||
border: 1.5px dashed var(--o14);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.pdf-drop:hover {
|
||||
border-color: var(--c-purple);
|
||||
background: var(--purple-bg);
|
||||
}
|
||||
|
||||
.pdf-drop--filled {
|
||||
border-style: solid;
|
||||
border-color: var(--purple-border);
|
||||
background: var(--purple-bg);
|
||||
}
|
||||
|
||||
.pdf-drop--drag {
|
||||
border-style: solid;
|
||||
border-color: var(--c-purple);
|
||||
background: var(--purple-bg);
|
||||
}
|
||||
|
||||
.pdf-drop__input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pdf-drop__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--o05);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pdf-drop--filled .pdf-drop__icon {
|
||||
background: var(--surface);
|
||||
color: var(--c-purple);
|
||||
}
|
||||
|
||||
.pdf-drop__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pdf-drop__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pdf-drop__meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.preserve {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
padding: 13px 14px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--o03);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preserve__box {
|
||||
flex-shrink: 0;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin-top: 1px;
|
||||
accent-color: var(--c-purple);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preserve__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.preserve__title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.preserve__desc {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.import-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--o03);
|
||||
border: 1px solid var(--o07);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.import-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 11px 18px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-import {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 11px 20px;
|
||||
background: var(--c-purple);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: var(--on-accent);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-import:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ size?: number }>(), { size: 40 })
|
||||
|
||||
const style = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
'--orb-blur': `${props.size * 0.05}px`,
|
||||
'--orb-inset': `${props.size * 0.21}px`,
|
||||
'--orb-shadow': `${props.size * 0.22}px`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="orb" :style="style" aria-hidden="true">
|
||||
<div class="orb__gradient"></div>
|
||||
<div class="orb__core"></div>
|
||||
<div class="orb__ring"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.orb {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.orb__gradient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
var(--c-purple),
|
||||
var(--c-teal),
|
||||
var(--accent-ink),
|
||||
var(--c-coral),
|
||||
var(--c-purple)
|
||||
);
|
||||
filter: blur(var(--orb-blur));
|
||||
animation: orb-spin 6s linear infinite;
|
||||
}
|
||||
|
||||
.orb__core {
|
||||
position: absolute;
|
||||
inset: var(--orb-inset);
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 40% 35%, rgba(255, 255, 255, 0.92), var(--o12) 55%, transparent 72%);
|
||||
animation: orb-pulse 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.orb__ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 var(--orb-shadow) rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
@keyframes orb-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes orb-pulse {
|
||||
0% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.4; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
import { useSubscriptionStorage } from '@/utils/storage'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Server-side plan verdict (settings.plan_allowed). false always locks. */
|
||||
planAllowed?: boolean | null
|
||||
}>()
|
||||
|
||||
const PRO_PRICE_NOTE = '包含在 Pro 套餐中 · 每位智能体 $39/月'
|
||||
|
||||
const router = useRouter()
|
||||
const { hasEnterpriseModule } = useEnterpriseFeatures()
|
||||
const subscriptionStorage = useSubscriptionStorage()
|
||||
|
||||
const isLocked = computed(() => {
|
||||
if (props.planAllowed === false) return true
|
||||
return (
|
||||
hasEnterpriseModule &&
|
||||
(!subscriptionStorage.hasFeature('help_center') || !subscriptionStorage.isSubscriptionActive())
|
||||
)
|
||||
})
|
||||
|
||||
function goToSubscription() {
|
||||
router.push('/settings/subscription')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lock-wrap">
|
||||
<div class="lock-wrap__content" :class="{ 'lock-wrap__content--locked': isLocked }">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="isLocked" class="lock-overlay">
|
||||
<div class="lock-card">
|
||||
<div class="lock-card__icon">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="var(--c-purple)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="10" rx="2.2" /><path d="M8 11V8a4 4 0 0 1 8 0v3" /></svg>
|
||||
</div>
|
||||
<h3 class="lock-card__title">用 Pro 自动生成常见问题</h3>
|
||||
<p class="lock-card__copy">
|
||||
ReplyPier 会读取每个知识来源,起草清晰、可直接发布的常见问题——按分类整理,并随文档更新保持同步。
|
||||
</p>
|
||||
<div class="lock-card__actions">
|
||||
<button class="btn-upgrade" type="button" @click="goToSubscription">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" stroke="none"><path d="M13 2 4 14h6l-1 8 9-12h-6z" /></svg>
|
||||
升级到 Pro
|
||||
</button>
|
||||
<button class="btn-compare" type="button" @click="goToSubscription">比较套餐</button>
|
||||
</div>
|
||||
<div class="lock-card__note">{{ PRO_PRICE_NOTE }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lock-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lock-wrap__content--locked {
|
||||
filter: grayscale(0.9) saturate(0.5);
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.lock-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--scrim);
|
||||
backdrop-filter: blur(6px);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.lock-card {
|
||||
max-width: 440px;
|
||||
text-align: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--purple-border);
|
||||
border-radius: 20px;
|
||||
padding: 34px 32px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.lock-card__icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin: 0 auto 18px;
|
||||
border-radius: 14px;
|
||||
background: var(--purple-bg);
|
||||
border: 1px solid var(--purple-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lock-card__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 21px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.lock-card__copy {
|
||||
font-size: 14.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
|
||||
.lock-card__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-upgrade {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 13px 22px;
|
||||
background: var(--c-purple);
|
||||
border: 1px solid var(--c-purple);
|
||||
border-radius: 11px;
|
||||
color: var(--on-accent);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-compare {
|
||||
padding: 13px 20px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: 11px;
|
||||
color: var(--text2);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lock-card__note {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
margin-top: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,301 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Per-article SEO overrides inside the FAQ editor: URL slug, the preserved URL
|
||||
path for a migrated article, meta title and meta description. Collapsed by
|
||||
default — every field is optional, and leaving one empty keeps the value the
|
||||
public page derives from the question and answer.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
slug?: string
|
||||
urlPath?: string
|
||||
metaTitle?: string
|
||||
metaDescription?: string
|
||||
/** Shown as the placeholder for the title, so the derived default is visible. */
|
||||
question?: string
|
||||
}>(),
|
||||
{ slug: '', urlPath: '', metaTitle: '', metaDescription: '', question: '' },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:slug': [value: string]
|
||||
'update:urlPath': [value: string]
|
||||
'update:metaTitle': [value: string]
|
||||
'update:metaDescription': [value: string]
|
||||
}>()
|
||||
|
||||
// Hard caps mirror the backend column widths in app/models/faq.py, which is the
|
||||
// source of truth — the API rejects anything longer with a 422, so these only
|
||||
// stop the user typing past the limit. The "recommended" numbers are softer:
|
||||
// where search engines start truncating the tag in results.
|
||||
const MAX_SLUG = 80
|
||||
const MAX_URL_PATH = 400
|
||||
const MAX_TITLE = 120
|
||||
const MAX_DESCRIPTION = 300
|
||||
const RECOMMENDED_TITLE = 60
|
||||
const RECOMMENDED_DESCRIPTION = 160
|
||||
|
||||
// A customised article opens the section on mount, so an override is never
|
||||
// hidden behind a collapsed panel — but it stays a plain toggle afterwards, so
|
||||
// the user can still fold it away. The editor mounts fresh per article, which
|
||||
// is what makes the initial value correct.
|
||||
const hasValues = computed(
|
||||
() => Boolean(props.urlPath.trim() || props.metaTitle.trim() || props.metaDescription.trim()),
|
||||
)
|
||||
const expanded = ref(hasValues.value)
|
||||
|
||||
const titleCount = computed(() => props.metaTitle.trim().length)
|
||||
const descriptionCount = computed(() => props.metaDescription.trim().length)
|
||||
|
||||
function onInput(event: Event): string {
|
||||
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="seo">
|
||||
<button class="seo__toggle" type="button" :aria-expanded="expanded" @click="expanded = !expanded">
|
||||
<svg
|
||||
class="seo__chevron"
|
||||
:class="{ 'seo__chevron--open': expanded }"
|
||||
viewBox="0 0 24 24"
|
||||
width="13"
|
||||
height="13"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
SEO 与 URL
|
||||
<span v-if="hasValues" class="seo__badge">已自定义</span>
|
||||
</button>
|
||||
|
||||
<div v-if="expanded" class="seo__fields">
|
||||
<label class="seo__field">
|
||||
<span class="seo__label">URL 别名</span>
|
||||
<span class="seo__slug">
|
||||
<span class="seo__prefix">/a/</span>
|
||||
<input
|
||||
type="text"
|
||||
class="seo__input seo__input--slug"
|
||||
:maxlength="MAX_SLUG"
|
||||
placeholder="根据问题自动生成"
|
||||
:value="slug"
|
||||
@input="emit('update:slug', onInput($event))"
|
||||
/>
|
||||
</span>
|
||||
<span class="seo__hint">
|
||||
修改后,指向该文章的现有链接将失效。空格和符号会转换为连字符。
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="seo__field">
|
||||
<span class="seo__label">自定义 URL 路径</span>
|
||||
<input
|
||||
type="text"
|
||||
class="seo__input seo__input--path"
|
||||
:maxlength="MAX_URL_PATH"
|
||||
placeholder="/hc/en-us/articles/360012-reset-password"
|
||||
:value="urlPath"
|
||||
@input="emit('update:urlPath', onInput($event))"
|
||||
/>
|
||||
<span class="seo__hint">
|
||||
将这篇文章发布到它在其他帮助中心已有的 URL 上,以保留搜索排名和外部链接。粘贴旧 URL 或路径即可,留空则使用上方的别名。
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="seo__field">
|
||||
<span class="seo__label">
|
||||
Meta 标题
|
||||
<span class="seo__count" :class="{ 'seo__count--over': titleCount > RECOMMENDED_TITLE }">
|
||||
{{ titleCount }}/{{ RECOMMENDED_TITLE }}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="seo__input"
|
||||
:maxlength="MAX_TITLE"
|
||||
:placeholder="question || '默认为问题内容'"
|
||||
:value="metaTitle"
|
||||
@input="emit('update:metaTitle', onInput($event))"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="seo__field">
|
||||
<span class="seo__label">
|
||||
Meta 描述
|
||||
<span
|
||||
class="seo__count"
|
||||
:class="{ 'seo__count--over': descriptionCount > RECOMMENDED_DESCRIPTION }"
|
||||
>
|
||||
{{ descriptionCount }}/{{ RECOMMENDED_DESCRIPTION }}
|
||||
</span>
|
||||
</span>
|
||||
<textarea
|
||||
class="seo__input seo__textarea"
|
||||
rows="2"
|
||||
:maxlength="MAX_DESCRIPTION"
|
||||
placeholder="默认为回答的开头部分"
|
||||
:value="metaDescription"
|
||||
@input="emit('update:metaDescription', onInput($event))"
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.seo {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--o08);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.seo__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.seo__toggle:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.seo__chevron {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.seo__chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.seo__badge {
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--o08);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.seo__fields {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.seo__field {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.seo__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.seo__count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: 400;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.seo__count--over {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.seo__slug {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seo__prefix {
|
||||
padding: 0 2px 0 11px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.seo__input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 9px;
|
||||
padding: 9px 11px;
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.seo__input:focus {
|
||||
border-color: var(--o20);
|
||||
}
|
||||
|
||||
/* The slug input sits inside the prefixed shell, which draws the border. */
|
||||
.seo__input--slug {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding-left: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* A URL, so it reads in the same monospace as the slug — but it keeps the
|
||||
standard input shell, since it has no "/a/" prefix to sit behind. */
|
||||
.seo__input--path {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.seo__textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.seo__hint {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,293 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import type { GenerateEstimate, GenerationSource } from '@/types/faq'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
estimate: GenerateEstimate | null
|
||||
submitting?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
generate: [knowledgeIds: number[]]
|
||||
}>()
|
||||
|
||||
const sources = computed<GenerationSource[]>(() => props.estimate?.sources ?? [])
|
||||
const selected = ref<Set<number>>(new Set())
|
||||
|
||||
// Default selection = sources without FAQs (the "new" ones). Seed only when the
|
||||
// picker OPENS (not on later estimate changes) so a background refetch can't
|
||||
// wipe the user's manual selection mid-interaction.
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
const next = new Set<number>()
|
||||
for (const s of sources.value) if (!s.has_faqs) next.add(s.id)
|
||||
// If everything already has FAQs, start from all so the user can regenerate.
|
||||
if (next.size === 0) for (const s of sources.value) next.add(s.id)
|
||||
selected.value = next
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function toggle(id: number) {
|
||||
const next = new Set(selected.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
selected.value = next
|
||||
}
|
||||
|
||||
const allSelected = computed(
|
||||
() => sources.value.length > 0 && selected.value.size === sources.value.length,
|
||||
)
|
||||
|
||||
function toggleAll() {
|
||||
selected.value = allSelected.value ? new Set() : new Set(sources.value.map((s) => s.id))
|
||||
}
|
||||
|
||||
const metered = computed(() => props.estimate?.metered ?? false)
|
||||
const remaining = computed(() => props.estimate?.remaining_credits ?? null)
|
||||
|
||||
const selectedCredits = computed(() =>
|
||||
sources.value.reduce((n, s) => (selected.value.has(s.id) ? n + s.estimated_calls : n), 0),
|
||||
)
|
||||
|
||||
const overBudget = computed(
|
||||
() => metered.value && remaining.value !== null && selectedCredits.value > remaining.value,
|
||||
)
|
||||
|
||||
const canGenerate = computed(() => selected.value.size > 0 && !props.submitting && !overBudget.value)
|
||||
|
||||
function labelFor(s: GenerationSource): string {
|
||||
const pages = s.pages ? `${s.pages} 个页面` : ''
|
||||
return pages
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!canGenerate.value) return
|
||||
emit('generate', [...selected.value])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-if="open" @close="$emit('close')">
|
||||
<template #title>生成常见问题</template>
|
||||
<template #content>
|
||||
<p class="picker-sub">选择要从哪些知识来源生成常见问题。</p>
|
||||
|
||||
<div class="picker-toolbar">
|
||||
<button class="link-btn" type="button" @click="toggleAll">
|
||||
{{ allSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
<span class="picker-count">已选 {{ selected.size }} / {{ sources.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="picker-list">
|
||||
<label
|
||||
v-for="s in sources"
|
||||
:key="s.id"
|
||||
class="src"
|
||||
:class="{ 'src--on': selected.has(s.id) }"
|
||||
>
|
||||
<input class="src__check" type="checkbox" :checked="selected.has(s.id)" @change="toggle(s.id)" />
|
||||
<span class="src__body">
|
||||
<span class="src__name" :title="s.name">{{ s.name }}</span>
|
||||
<span class="src__meta">
|
||||
<span class="src__type">{{ s.source_type }}</span>
|
||||
<template v-if="labelFor(s)"> · {{ labelFor(s) }}</template>
|
||||
<span v-if="s.has_faqs" class="src__badge">已生成</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="metered" class="picker-credits" :class="{ 'picker-credits--over': overBudget }">
|
||||
预计消耗约 {{ selectedCredits }} 条额度<template v-if="remaining !== null"> · 剩余 {{ remaining }} 条</template>
|
||||
<template v-if="overBudget">——额度不足,请升级套餐或改用你自己的 AI 模型。</template>
|
||||
</p>
|
||||
|
||||
<div class="picker-actions">
|
||||
<button class="btn-cancel" type="button" @click="$emit('close')">取消</button>
|
||||
<button class="btn-generate" type="button" :disabled="!canGenerate" @click="submit">
|
||||
{{ submitting ? '正在启动…' : `从 ${selected.size} 个来源生成` }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.picker-sub {
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
margin: -12px 0 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.picker-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--c-purple);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.picker-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.picker-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 42vh;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.src {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
padding: 12px 13px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.src--on {
|
||||
border-color: var(--purple-border);
|
||||
background: var(--purple-bg);
|
||||
}
|
||||
|
||||
.src__check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 1px 0 0;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--c-purple);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.src__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.src__name {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.src__meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.src__type {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.src__badge {
|
||||
padding: 1px 7px;
|
||||
border-radius: 6px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
font-size: 10.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.picker-credits {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.picker-credits--over {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.picker-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 11px 18px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-generate {
|
||||
padding: 11px 20px;
|
||||
background: var(--purple-bg);
|
||||
border: 1px solid var(--purple-border);
|
||||
border-radius: 10px;
|
||||
color: var(--c-purple);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-generate:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,857 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { FaqImportMode, FaqItem, FaqStatus } from '@/types/faq'
|
||||
import { useFaqWorkspace } from '@/composables/useFaqWorkspace'
|
||||
import { useHelpCenterSettings } from '@/composables/useHelpCenterSettings'
|
||||
import FaqGenerateBar from './FaqGenerateBar.vue'
|
||||
import FaqEmptyState from './FaqEmptyState.vue'
|
||||
import FaqGenerationProgress from './FaqGenerationProgress.vue'
|
||||
import FaqCategoryGroup from './FaqCategoryGroup.vue'
|
||||
import FaqCard from './FaqCard.vue'
|
||||
import FaqImportModal from './FaqImportModal.vue'
|
||||
import FaqSourcePicker from './FaqSourcePicker.vue'
|
||||
import HelpCenterAppearance from './HelpCenterAppearance.vue'
|
||||
import HelpCenterPublic from './HelpCenterPublic.vue'
|
||||
|
||||
const props = defineProps<{ organizationId?: string }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'plan-allowed': [allowed: boolean]
|
||||
}>()
|
||||
|
||||
const {
|
||||
faqs,
|
||||
job,
|
||||
settings,
|
||||
estimate,
|
||||
fetchEstimate,
|
||||
sourceCount,
|
||||
pageCount,
|
||||
phase,
|
||||
barPhase,
|
||||
publishedCount,
|
||||
groupedFaqs,
|
||||
searchQuery,
|
||||
categoryFilter,
|
||||
statusFilter,
|
||||
categories,
|
||||
hasActiveFilters,
|
||||
filteredCount,
|
||||
resetFilters,
|
||||
toggleCategory,
|
||||
isCategoryOpen,
|
||||
editingId,
|
||||
isNewFaq,
|
||||
draftQuestion,
|
||||
draftAnswer,
|
||||
draftCategory,
|
||||
draftSlug,
|
||||
draftUrlPath,
|
||||
draftMetaTitle,
|
||||
draftMetaDescription,
|
||||
isSaving,
|
||||
selectedIds,
|
||||
selectionActive,
|
||||
toggleSelect,
|
||||
setSelected,
|
||||
clearSelection,
|
||||
bulkSetStatus,
|
||||
bulkDelete,
|
||||
refresh,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
startGeneration,
|
||||
submitImport,
|
||||
submitPdfImport,
|
||||
togglePublish,
|
||||
startEdit,
|
||||
startAdd,
|
||||
cancelEdit,
|
||||
saveEdit,
|
||||
deleteFaq,
|
||||
} = useFaqWorkspace(() => props.organizationId)
|
||||
|
||||
const {
|
||||
saveState,
|
||||
queueSave,
|
||||
saveNow,
|
||||
uploadLogo,
|
||||
removeLogo,
|
||||
uploadFavicon,
|
||||
removeFavicon,
|
||||
domainBusy,
|
||||
setDomain,
|
||||
verifyDomain,
|
||||
removeDomain,
|
||||
} = useHelpCenterSettings(settings)
|
||||
|
||||
// Surface the server-side plan verdict to the view's lock overlay.
|
||||
watch(
|
||||
() => settings.value?.plan_allowed,
|
||||
(allowed) => {
|
||||
if (typeof allowed === 'boolean') emit('plan-allowed', allowed)
|
||||
},
|
||||
)
|
||||
|
||||
defineExpose({ settings })
|
||||
|
||||
// Tabs keep the (potentially long) FAQ list and the settings on separate
|
||||
// panes so neither forces the other into a long scroll.
|
||||
const tab = ref<'faqs' | 'settings'>('faqs')
|
||||
|
||||
// Import modal state.
|
||||
const importOpen = ref(false)
|
||||
const importSubmitting = ref(false)
|
||||
|
||||
// Knowledge-source picker (shown when there are multiple sources).
|
||||
const sourcePickerOpen = ref(false)
|
||||
const generating = ref(false)
|
||||
|
||||
async function onPickSources(knowledgeIds: number[]) {
|
||||
generating.value = true
|
||||
try {
|
||||
// Keep the picker open if the job didn't start (409/budget) — the error
|
||||
// toast explains why and the user can adjust their selection.
|
||||
if (await startGeneration(knowledgeIds)) sourcePickerOpen.value = false
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onImportSubmit(url: string, mode: FaqImportMode, preserveUrls: boolean) {
|
||||
importSubmitting.value = true
|
||||
try {
|
||||
const ok = await submitImport(url, mode, preserveUrls)
|
||||
if (ok) importOpen.value = false
|
||||
} finally {
|
||||
importSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onPdfImportSubmit(file: File) {
|
||||
importSubmitting.value = true
|
||||
try {
|
||||
const ok = await submitPdfImport(file)
|
||||
if (ok) importOpen.value = false
|
||||
} finally {
|
||||
importSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// A single reusable confirm dialog for deletes and generation.
|
||||
interface ConfirmState {
|
||||
title: string
|
||||
message: string
|
||||
actionLabel: string
|
||||
busyLabel: string
|
||||
intent?: 'danger' | 'primary'
|
||||
disabledReason?: string
|
||||
action: () => Promise<void>
|
||||
}
|
||||
const confirmState = ref<ConfirmState | null>(null)
|
||||
const confirmBusy = ref(false)
|
||||
|
||||
function askDelete(faq: FaqItem) {
|
||||
confirmState.value = {
|
||||
title: '删除常见问题',
|
||||
message: `删除“${faq.question}”?此操作无法撤销。`,
|
||||
actionLabel: '删除',
|
||||
busyLabel: '正在删除…',
|
||||
intent: 'danger',
|
||||
action: () => deleteFaq(faq),
|
||||
}
|
||||
}
|
||||
|
||||
async function askGenerate() {
|
||||
await fetchEstimate(true) // full estimate incl. page counts for the dialog
|
||||
const e = estimate.value
|
||||
if (!e) {
|
||||
// Estimate unavailable (e.g. plan check failed) — let the backend decide.
|
||||
await startGeneration()
|
||||
return
|
||||
}
|
||||
if (e.total_sources === 0) {
|
||||
toast.error('没有可用于生成的知识来源,请先添加知识。')
|
||||
return
|
||||
}
|
||||
// Multiple sources → let the user pick which to generate from.
|
||||
if (e.sources.length > 1) {
|
||||
sourcePickerOpen.value = true
|
||||
return
|
||||
}
|
||||
if (e.new_sources === 0) {
|
||||
toast.info('所有知识来源都已生成常见问题,没有新内容可生成。')
|
||||
return
|
||||
}
|
||||
const sources = `${e.new_sources} 个新来源`
|
||||
const pages = e.pages ? `(约 ${e.pages} 个页面)` : ''
|
||||
let message = `根据 ${sources}${pages}生成常见问题?`
|
||||
let disabledReason: string | undefined
|
||||
if (e.metered) {
|
||||
message += ` 预计消耗约 ${e.estimated_calls} 条消息额度。`
|
||||
if (e.remaining_credits !== null && e.estimated_calls > e.remaining_credits) {
|
||||
disabledReason = `本周期仅剩 ${e.remaining_credits} 条额度——请升级套餐或改用你自己的 AI 模型。`
|
||||
}
|
||||
}
|
||||
confirmState.value = {
|
||||
title: '生成常见问题',
|
||||
message,
|
||||
actionLabel: '生成',
|
||||
busyLabel: '正在启动…',
|
||||
intent: 'primary',
|
||||
disabledReason,
|
||||
action: async () => {
|
||||
await startGeneration()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function askBulkDelete() {
|
||||
const n = selectedIds.value.size
|
||||
confirmState.value = {
|
||||
title: `删除 ${n} 条常见问题`,
|
||||
message: '已发布的文章将从帮助中心移除,此操作无法撤销。',
|
||||
actionLabel: '删除',
|
||||
busyLabel: '正在删除…',
|
||||
intent: 'danger',
|
||||
action: async () => {
|
||||
try {
|
||||
await bulkDelete()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function selectedInCategory(items: FaqItem[]): number {
|
||||
return items.reduce((n, item) => n + (selectedIds.value.has(item.id) ? 1 : 0), 0)
|
||||
}
|
||||
|
||||
function publishedInCategory(items: FaqItem[]): number {
|
||||
return items.reduce((n, item) => n + (item.status === 'published' ? 1 : 0), 0)
|
||||
}
|
||||
|
||||
const STATUS_FILTERS: { v: 'all' | FaqStatus; l: string }[] = [
|
||||
{ v: 'all', l: '全部' },
|
||||
{ v: 'published', l: '已发布' },
|
||||
{ v: 'draft', l: '草稿' },
|
||||
]
|
||||
|
||||
async function runConfirm() {
|
||||
const state = confirmState.value
|
||||
if (!state) return
|
||||
confirmBusy.value = true
|
||||
try {
|
||||
await state.action()
|
||||
} finally {
|
||||
confirmBusy.value = false
|
||||
confirmState.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder card for a manually added FAQ. The category mirrors the server
|
||||
// default applied when the topic field is left blank.
|
||||
const newFaqStub = computed<FaqItem>(() => ({
|
||||
id: 'new',
|
||||
question: '',
|
||||
answer: '',
|
||||
category: 'General',
|
||||
status: 'draft',
|
||||
knowledge_id: null,
|
||||
source_label: '手动添加',
|
||||
}))
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onUnmounted(stopPolling)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="faq-workspace">
|
||||
<div v-if="phase === 'loading'" class="loading">正在加载常见问题…</div>
|
||||
|
||||
<template v-else>
|
||||
<nav class="tabs" role="tablist">
|
||||
<button class="tab" :class="{ 'tab--active': tab === 'faqs' }" type="button" role="tab" :aria-selected="tab === 'faqs'" @click="tab = 'faqs'">
|
||||
常见问题
|
||||
<span v-if="faqs.length" class="tab__count">{{ faqs.length }}</span>
|
||||
</button>
|
||||
<button class="tab" :class="{ 'tab--active': tab === 'settings' }" type="button" role="tab" :aria-selected="tab === 'settings'" @click="tab = 'settings'">
|
||||
自定义
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div v-show="tab === 'faqs'" class="tab-panel">
|
||||
<FaqGenerateBar
|
||||
class="workspace-bar"
|
||||
:phase="barPhase"
|
||||
:source-count="sourceCount"
|
||||
:page-count="pageCount"
|
||||
:faq-count="faqs.length"
|
||||
:published-count="publishedCount"
|
||||
:new-source-count="estimate?.new_sources ?? null"
|
||||
@generate="askGenerate"
|
||||
@import="importOpen = true"
|
||||
@add="startAdd"
|
||||
/>
|
||||
|
||||
<FaqEmptyState
|
||||
v-if="phase === 'empty' && !isNewFaq"
|
||||
:source-count="sourceCount"
|
||||
:page-count="pageCount"
|
||||
@generate="askGenerate"
|
||||
@import="importOpen = true"
|
||||
@add="startAdd"
|
||||
/>
|
||||
|
||||
<FaqGenerationProgress v-else-if="phase === 'generating' && job" :job="job" />
|
||||
|
||||
<template v-else>
|
||||
<div v-if="isNewFaq" class="faq-list faq-list--new">
|
||||
<FaqCard
|
||||
:faq="newFaqStub"
|
||||
:editing="true"
|
||||
:is-new="true"
|
||||
:saving="isSaving"
|
||||
:categories="categories"
|
||||
v-model:draft-question="draftQuestion"
|
||||
v-model:draft-answer="draftAnswer"
|
||||
v-model:draft-category="draftCategory"
|
||||
v-model:draft-slug="draftSlug"
|
||||
v-model:draft-url-path="draftUrlPath"
|
||||
v-model:draft-meta-title="draftMetaTitle"
|
||||
v-model:draft-meta-description="draftMetaDescription"
|
||||
@save="saveEdit"
|
||||
@cancel="cancelEdit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search + filter toolbar (only once there are FAQs to filter) -->
|
||||
<template v-if="faqs.length">
|
||||
<div class="faq-toolbar">
|
||||
<div class="faq-search">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
|
||||
<input v-model="searchQuery" type="search" placeholder="搜索问题与答案…" aria-label="搜索常见问题" />
|
||||
</div>
|
||||
<select v-model="categoryFilter" class="faq-select" aria-label="按分类筛选">
|
||||
<option :value="null">全部分类</option>
|
||||
<option v-for="c in categories" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<div class="faq-status-filter" role="group" aria-label="按状态筛选">
|
||||
<button
|
||||
v-for="opt in STATUS_FILTERS"
|
||||
:key="opt.v"
|
||||
class="faq-status-filter__btn"
|
||||
:class="{ 'faq-status-filter__btn--active': statusFilter === opt.v }"
|
||||
type="button"
|
||||
@click="statusFilter = opt.v"
|
||||
>{{ opt.l }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasActiveFilters" class="faq-resultcount">显示 {{ filteredCount }} / {{ faqs.length }} 条</div>
|
||||
</template>
|
||||
|
||||
<div v-if="faqs.length && groupedFaqs.size === 0" class="faq-noresults">
|
||||
<p>没有符合筛选条件的常见问题。</p>
|
||||
<button v-if="hasActiveFilters" class="faq-noresults__clear" type="button" @click="resetFilters">清除筛选</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="groupedFaqs.size" class="faq-list">
|
||||
<FaqCategoryGroup
|
||||
v-for="[category, items] in groupedFaqs"
|
||||
:key="category"
|
||||
:category="category"
|
||||
:count="items.length"
|
||||
:published-count="publishedInCategory(items)"
|
||||
:selected-count="selectedInCategory(items)"
|
||||
:open="isCategoryOpen(category)"
|
||||
@toggle="toggleCategory(category)"
|
||||
@toggle-all="setSelected(items, $event)"
|
||||
>
|
||||
<FaqCard
|
||||
v-for="faq in items"
|
||||
:key="faq.id"
|
||||
:faq="faq"
|
||||
:editing="editingId === faq.id"
|
||||
:saving="isSaving"
|
||||
:selectable="selectionActive"
|
||||
:selected="selectedIds.has(faq.id)"
|
||||
:categories="categories"
|
||||
:draft-question="draftQuestion"
|
||||
:draft-answer="draftAnswer"
|
||||
:draft-category="draftCategory"
|
||||
:draft-slug="draftSlug"
|
||||
:draft-url-path="draftUrlPath"
|
||||
:draft-meta-title="draftMetaTitle"
|
||||
:draft-meta-description="draftMetaDescription"
|
||||
@update:draft-question="draftQuestion = $event"
|
||||
@update:draft-answer="draftAnswer = $event"
|
||||
@update:draft-category="draftCategory = $event"
|
||||
@update:draft-slug="draftSlug = $event"
|
||||
@update:draft-url-path="draftUrlPath = $event"
|
||||
@update:draft-meta-title="draftMetaTitle = $event"
|
||||
@update:draft-meta-description="draftMetaDescription = $event"
|
||||
@toggle-select="toggleSelect(faq.id)"
|
||||
@toggle-status="togglePublish(faq)"
|
||||
@edit="startEdit(faq)"
|
||||
@delete="askDelete(faq)"
|
||||
@save="saveEdit"
|
||||
@cancel="cancelEdit"
|
||||
/>
|
||||
</FaqCategoryGroup>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-show="tab === 'settings'" class="tab-panel">
|
||||
<template v-if="settings">
|
||||
<HelpCenterAppearance
|
||||
class="settings-section"
|
||||
:settings="settings"
|
||||
:save-state="saveState"
|
||||
@update="queueSave"
|
||||
@save-now="saveNow"
|
||||
@upload-logo="uploadLogo"
|
||||
@remove-logo="removeLogo"
|
||||
@upload-favicon="uploadFavicon"
|
||||
@remove-favicon="removeFavicon"
|
||||
/>
|
||||
<HelpCenterPublic
|
||||
class="settings-section"
|
||||
:settings="settings"
|
||||
:domain-busy="domainBusy"
|
||||
@toggle-enabled="saveNow({ enabled: $event })"
|
||||
@update-ai="saveNow($event)"
|
||||
@set-domain="setDomain"
|
||||
@verify-domain="verifyDomain"
|
||||
@remove-domain="removeDomain"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="loading">正在加载设置…</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<FaqImportModal :open="importOpen" :submitting="importSubmitting" @close="importOpen = false" @submit="onImportSubmit" @submit-pdf="onPdfImportSubmit" />
|
||||
|
||||
<FaqSourcePicker
|
||||
:open="sourcePickerOpen"
|
||||
:estimate="estimate"
|
||||
:submitting="generating"
|
||||
@close="sourcePickerOpen = false"
|
||||
@generate="onPickSources"
|
||||
/>
|
||||
|
||||
<div v-if="selectionActive" class="bulkbar" role="toolbar" aria-label="批量操作">
|
||||
<span class="bulkbar__count">已选 {{ selectedIds.size }} 条</span>
|
||||
<button class="bulkbar__btn" type="button" @click="bulkSetStatus('published')">发布</button>
|
||||
<button class="bulkbar__btn" type="button" @click="bulkSetStatus('draft')">取消发布</button>
|
||||
<button class="bulkbar__btn bulkbar__btn--danger" type="button" @click="askBulkDelete">删除</button>
|
||||
<button class="bulkbar__clear" type="button" aria-label="清除选择" @click="clearSelection">✕</button>
|
||||
</div>
|
||||
|
||||
<div v-if="confirmState" class="confirm" role="dialog" aria-modal="true">
|
||||
<div class="confirm__card">
|
||||
<h3 class="confirm__title">{{ confirmState.title }}</h3>
|
||||
<p class="confirm__msg">{{ confirmState.message }}</p>
|
||||
<p v-if="confirmState.disabledReason" class="confirm__blocked">{{ confirmState.disabledReason }}</p>
|
||||
<div class="confirm__actions">
|
||||
<button class="confirm__cancel" type="button" :disabled="confirmBusy" @click="confirmState = null">取消</button>
|
||||
<button
|
||||
class="confirm__go"
|
||||
:class="confirmState.intent === 'primary' ? 'confirm__go--primary' : 'confirm__go--danger'"
|
||||
type="button"
|
||||
:disabled="confirmBusy || !!confirmState.disabledReason"
|
||||
@click="runConfirm"
|
||||
>
|
||||
{{ confirmBusy ? confirmState.busyLabel : confirmState.actionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.faq-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--o08);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab--active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--c-teal);
|
||||
}
|
||||
|
||||
.tab__count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--o08);
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.tab--active .tab__count {
|
||||
background: var(--teal-bg);
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.workspace-bar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.faq-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.faq-list--new {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.faq-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.faq-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 0 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.faq-search input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.faq-select {
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 11px;
|
||||
color: var(--text2);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.faq-status-filter {
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.faq-status-filter__btn {
|
||||
padding: 7px 13px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.faq-status-filter__btn--active {
|
||||
background: var(--surface);
|
||||
color: var(--text2);
|
||||
box-shadow: 0 1px 3px var(--scrim);
|
||||
}
|
||||
|
||||
.faq-resultcount {
|
||||
margin: -8px 0 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.faq-noresults {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.faq-noresults__clear {
|
||||
margin-top: 12px;
|
||||
padding: 8px 16px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 10px;
|
||||
color: var(--text2);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-section:not(:first-child) {
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
.bulkbar {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-pill);
|
||||
box-shadow: 0 8px 30px var(--scrim);
|
||||
}
|
||||
|
||||
.bulkbar__count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.bulkbar__btn {
|
||||
padding: 8px 14px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--text2);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bulkbar__btn:hover {
|
||||
background: var(--o08);
|
||||
}
|
||||
|
||||
.bulkbar__btn--danger {
|
||||
background: var(--coral-bg);
|
||||
border-color: var(--coral-border);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.bulkbar__clear {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bulkbar__clear:hover {
|
||||
background: var(--o08);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.confirm {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--scrim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.confirm__card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.confirm__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.confirm__msg {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.confirm__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.confirm__cancel {
|
||||
padding: 10px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.confirm__go {
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.confirm__go:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.confirm__go--danger {
|
||||
background: var(--coral-bg);
|
||||
border: 1px solid var(--coral-border);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.confirm__go--primary {
|
||||
background: var(--purple-bg);
|
||||
border: 1px solid var(--purple-border);
|
||||
color: var(--c-purple);
|
||||
}
|
||||
|
||||
.confirm__blocked {
|
||||
font-size: 13px;
|
||||
color: var(--c-coral);
|
||||
background: var(--coral-bg);
|
||||
border: 1px solid var(--coral-border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
margin: -8px 0 20px;
|
||||
}
|
||||
|
||||
/* ── Mobile ─────────────────────────────────────────────────────────────
|
||||
The toolbar wrapped into a ragged column: the search box stretched but the
|
||||
select and the status group sat at content width, so the select's arrow
|
||||
landed mid-row instead of at the edge. Each control takes the full row. */
|
||||
@media (max-width: 768px) {
|
||||
.faq-toolbar {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.faq-search,
|
||||
.faq-select,
|
||||
.faq-status-filter {
|
||||
flex: 1 0 100%;
|
||||
min-width: 0;
|
||||
/* 44px minimum touch target (Apple HIG / WCAG 2.5.8) */
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* 16px keeps iOS from zooming the page when either field takes focus */
|
||||
.faq-search input,
|
||||
.faq-select {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.faq-select {
|
||||
width: 100%;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.faq-status-filter__btn {
|
||||
flex: 1;
|
||||
min-height: 38px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,592 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { HelpCenterSettings, HelpCenterSettingsUpdate } from '@/types/faq'
|
||||
import type { SaveState } from '@/composables/useHelpCenterSettings'
|
||||
import HelpCenterLogoField from './HelpCenterLogoField.vue'
|
||||
import HelpCenterPreview from './HelpCenterPreview.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
settings: HelpCenterSettings
|
||||
saveState: SaveState
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [payload: HelpCenterSettingsUpdate]
|
||||
'save-now': [payload: HelpCenterSettingsUpdate]
|
||||
'upload-logo': [file: File]
|
||||
'remove-logo': []
|
||||
'upload-favicon': [file: File]
|
||||
'remove-favicon': []
|
||||
}>()
|
||||
|
||||
const BRAND_SWATCHES = ['#4338CA', '#0E8C8C', '#CF5B38', '#6D5BD0', '#1F8A5B', '#2A6FDB']
|
||||
|
||||
// Custom brand color: native picker + hex field, both saving immediately.
|
||||
const isPreset = computed(() =>
|
||||
BRAND_SWATCHES.some((c) => c.toLowerCase() === (props.settings.brand_color || '').toLowerCase()),
|
||||
)
|
||||
|
||||
const hexDraft = ref(props.settings.brand_color)
|
||||
watch(() => props.settings.brand_color, (v) => { hexDraft.value = v })
|
||||
|
||||
// The native <input type=color> only accepts #rrggbb — expand 3-digit and drop
|
||||
// alpha from 8-digit so opening the picker starts on the real color, never
|
||||
// silently resetting to black.
|
||||
const pickerValue = computed(() => {
|
||||
const body = (props.settings.brand_color || '').replace(/^#/, '')
|
||||
if (body.length === 3) return `#${[...body].map((c) => c + c).join('')}`
|
||||
if (body.length >= 6) return `#${body.slice(0, 6)}`
|
||||
return '#000000'
|
||||
})
|
||||
|
||||
function saveColor(value: string) {
|
||||
if (value.toLowerCase() !== (props.settings.brand_color || '').toLowerCase()) {
|
||||
emit('save-now', { brand_color: value })
|
||||
}
|
||||
}
|
||||
|
||||
function onPicker(event: Event) {
|
||||
saveColor((event.target as HTMLInputElement).value.toUpperCase())
|
||||
}
|
||||
|
||||
function commitHex() {
|
||||
const raw = hexDraft.value.trim()
|
||||
const body = raw.replace(/^#/, '')
|
||||
// Accept 3, 6 or 8 hex digits (matches the backend validator).
|
||||
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$|^[0-9a-fA-F]{8}$/.test(body)) {
|
||||
toast.error('请输入类似 #4338CA 的十六进制颜色')
|
||||
hexDraft.value = props.settings.brand_color
|
||||
return
|
||||
}
|
||||
const hex = `#${body.toUpperCase()}`
|
||||
hexDraft.value = hex
|
||||
saveColor(hex)
|
||||
}
|
||||
|
||||
const saveStateLabel = computed(() => {
|
||||
if (props.saveState === 'saving') return '保存中…'
|
||||
if (props.saveState === 'saved') return '已保存'
|
||||
if (props.saveState === 'error') return '保存失败,再次编辑可重试'
|
||||
return ''
|
||||
})
|
||||
|
||||
/** Text fields: mutate in place for instant feedback, emit for debounced save. */
|
||||
function onText(field: 'cta_text' | 'cta_url', event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
props.settings[field] = value
|
||||
emit('update', { [field]: value })
|
||||
}
|
||||
|
||||
// Header link rows are mutated in place — the settings engine deep-watches
|
||||
// header_links and debounce-saves.
|
||||
function addLink() {
|
||||
props.settings.header_links.push({ label: '新链接', url: '' })
|
||||
}
|
||||
|
||||
function removeLink(index: number) {
|
||||
props.settings.header_links.splice(index, 1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="appearance">
|
||||
<div class="section-head">
|
||||
<h2 class="section-head__title">帮助中心外观</h2>
|
||||
<span class="section-head__sub">Logo、颜色与页头链接</span>
|
||||
<span v-if="saveStateLabel" class="section-head__save" :class="`is-${saveState}`">{{ saveStateLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="appearance__card">
|
||||
<!-- controls -->
|
||||
<div class="controls">
|
||||
<div>
|
||||
<label class="mono-label">Logo</label>
|
||||
<HelpCenterLogoField
|
||||
:image-url="settings.logo_url"
|
||||
kind="logo"
|
||||
@upload="$emit('upload-logo', $event)"
|
||||
@remove="$emit('remove-logo')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mono-label">Favicon</label>
|
||||
<HelpCenterLogoField
|
||||
:image-url="settings.favicon_url"
|
||||
kind="favicon"
|
||||
:max-bytes="1048576"
|
||||
:output-max="128"
|
||||
square
|
||||
@upload="$emit('upload-favicon', $event)"
|
||||
@remove="$emit('remove-favicon')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mono-label">品牌色</label>
|
||||
<div class="swatches">
|
||||
<button
|
||||
v-for="color in BRAND_SWATCHES"
|
||||
:key="color"
|
||||
class="swatch"
|
||||
:class="{ 'swatch--selected': settings.brand_color.toLowerCase() === color.toLowerCase() }"
|
||||
:style="{ background: color, boxShadow: settings.brand_color.toLowerCase() === color.toLowerCase() ? `0 0 0 2px ${color}` : 'none' }"
|
||||
type="button"
|
||||
:title="color"
|
||||
@click="$emit('save-now', { brand_color: color })"
|
||||
></button>
|
||||
|
||||
<!-- Custom color: swatch opens the OS picker; shows current color when off-preset. -->
|
||||
<label
|
||||
class="swatch swatch--custom"
|
||||
:class="{ 'swatch--selected': !isPreset }"
|
||||
:style="!isPreset ? { background: settings.brand_color, boxShadow: `0 0 0 2px ${settings.brand_color}` } : {}"
|
||||
title="自定义颜色"
|
||||
>
|
||||
<input type="color" class="swatch__picker" :value="pickerValue" @change="onPicker" />
|
||||
<svg v-if="isPreset" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="hex-field">
|
||||
<span class="hex-field__hash">#</span>
|
||||
<input
|
||||
:value="hexDraft.replace(/^#/, '')"
|
||||
maxlength="8"
|
||||
spellcheck="false"
|
||||
placeholder="4338CA"
|
||||
aria-label="品牌色十六进制色值"
|
||||
@input="hexDraft = ($event.target as HTMLInputElement).value"
|
||||
@keydown.enter="commitHex"
|
||||
@blur="commitHex"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="hint">用于重新着色帮助中心的按钮、链接、搜索框和高亮元素。</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mono-label">页头链接</label>
|
||||
<p class="hint hint--tight">显示在帮助中心页头的链接——可指向你的官网。</p>
|
||||
<div class="link-rows">
|
||||
<div v-for="(link, index) in settings.header_links" :key="index" class="link-row">
|
||||
<input v-model="link.label" class="text-input text-input--label" type="text" placeholder="名称" />
|
||||
<div class="url-input">
|
||||
<span class="url-input__prefix">https://</span>
|
||||
<input v-model="link.url" type="text" placeholder="yoursite.com" />
|
||||
</div>
|
||||
<button class="remove-btn" type="button" title="移除" @click="removeLink(index)">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="add-link" type="button" @click="addLink">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
添加链接
|
||||
</button>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="row-head">
|
||||
<label class="mono-label mono-label--inline">主按钮</label>
|
||||
<label class="switch switch--sm">
|
||||
<input type="checkbox" :checked="settings.cta_enabled" @change="$emit('save-now', { cta_enabled: ($event.target as HTMLInputElement).checked })" />
|
||||
<span class="switch__track"><span class="switch__knob"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="link-row" :class="{ 'link-row--off': !settings.cta_enabled }">
|
||||
<input class="text-input text-input--label" type="text" placeholder="名称" :disabled="!settings.cta_enabled" :value="settings.cta_text || ''" @input="onText('cta_text', $event)" />
|
||||
<div class="url-input">
|
||||
<span class="url-input__prefix">https://</span>
|
||||
<input type="text" placeholder="app.yoursite.com" :disabled="!settings.cta_enabled" :value="settings.cta_url || ''" @input="onText('cta_url', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- live preview -->
|
||||
<div class="preview-col">
|
||||
<label class="mono-label">实时预览</label>
|
||||
<HelpCenterPreview :settings="settings" />
|
||||
<p class="preview-note">
|
||||
<span class="preview-note__url">{{ settings.live_url }}</span>
|
||||
上的修改会立即应用到已发布的帮助中心。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-head__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 19px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-head__sub,
|
||||
.section-head__save {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.section-head__save {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.section-head__save.is-saved {
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.section-head__save.is-error {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.appearance__card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 18px;
|
||||
padding: 22px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.05fr;
|
||||
gap: 26px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.appearance__card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mono-label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.swatch--selected {
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
.swatch--custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--o05);
|
||||
border: 2px dashed var(--o25);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.swatch--custom.swatch--selected {
|
||||
border-style: solid;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* The native color input fills the swatch but stays invisible — the label is
|
||||
the visible control and clicking anywhere opens the OS picker. */
|
||||
.swatch__picker {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hex-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 9px;
|
||||
padding: 0 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.hex-field__hash {
|
||||
color: var(--faint);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hex-field input {
|
||||
width: 84px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
padding: 9px 2px 9px 4px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
margin: 11px 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hint--tight {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.link-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 9px;
|
||||
padding: 9px 11px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.text-input--label {
|
||||
width: 112px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 9px;
|
||||
padding: 0 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.url-input__prefix {
|
||||
color: var(--faint);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.url-input input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
padding: 9px 3px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
border-color: var(--coral-border);
|
||||
}
|
||||
|
||||
.add-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 9px 14px;
|
||||
background: var(--o05);
|
||||
border: 1px dashed var(--o16);
|
||||
border-radius: 9px;
|
||||
color: var(--text2);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-link:hover {
|
||||
background: var(--o08);
|
||||
border-color: var(--o25);
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--o07);
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mono-label--inline {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.link-row--off {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.link-row--off .text-input,
|
||||
.link-row--off .url-input {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Toggle switch (scoped copy of the shared switch styling). */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.switch__track {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--o12);
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
|
||||
.switch__knob {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.switch input:checked + .switch__track {
|
||||
background: var(--c-teal);
|
||||
}
|
||||
|
||||
.switch input:checked + .switch__track .switch__knob {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.switch--sm .switch__track {
|
||||
width: 34px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.switch--sm .switch__knob {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.switch--sm input:checked + .switch__track .switch__knob {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
|
||||
.preview-col {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-note {
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
margin: 11px 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.preview-note__url {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,503 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { HelpCenterDomain } from '@/types/faq'
|
||||
|
||||
const props = defineProps<{
|
||||
domain: HelpCenterDomain | null
|
||||
busy: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'set-domain': [domain: string]
|
||||
'verify-domain': []
|
||||
'remove-domain': []
|
||||
}>()
|
||||
|
||||
const domainInput = ref(props.domain?.custom_domain || '')
|
||||
|
||||
watch(
|
||||
() => props.domain?.custom_domain,
|
||||
(value) => {
|
||||
domainInput.value = value || ''
|
||||
},
|
||||
)
|
||||
|
||||
const currentDomain = computed(() => props.domain?.custom_domain || '')
|
||||
const normalizedInput = computed(() => domainInput.value.trim().replace(/^https?:\/\//i, ''))
|
||||
const isVerified = computed(() => currentDomain.value !== '' && props.domain?.domain_status === 'verified')
|
||||
|
||||
// empty → enter a domain · pending → add DNS records + verify · connected → live
|
||||
const phase = computed<'empty' | 'pending' | 'connected'>(() => {
|
||||
if (!currentDomain.value) return 'empty'
|
||||
return isVerified.value ? 'connected' : 'pending'
|
||||
})
|
||||
|
||||
const statusPill = computed(() => {
|
||||
if (phase.value === 'connected') return { label: '已连接', cls: 'status-pill--teal' }
|
||||
if (phase.value === 'pending') return { label: '待验证', cls: 'status-pill--warn' }
|
||||
return { label: '未配置', cls: 'status-pill--idle' }
|
||||
})
|
||||
|
||||
const sslPill = computed(() => {
|
||||
const status = props.domain?.ssl_status
|
||||
if (status === 'active') return { label: '已启用', cls: 'record-pill--teal' }
|
||||
if (status === 'failed') return { label: '失败', cls: 'record-pill--coral' }
|
||||
return { label: '签发中', cls: 'record-pill--idle' }
|
||||
})
|
||||
|
||||
const liveUrl = computed(() => `https://${currentDomain.value}`)
|
||||
|
||||
function addDomain() {
|
||||
if (!normalizedInput.value || props.busy) return
|
||||
emit('set-domain', normalizedInput.value)
|
||||
}
|
||||
|
||||
async function copy(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.success('已复制')
|
||||
} catch {
|
||||
toast.error('无法复制,请手动选择并复制')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="domain-head">
|
||||
<h3 class="domain-head__title">自定义域名</h3>
|
||||
<span class="status-pill" :class="statusPill.cls">
|
||||
<span class="status-pill__dot"></span>
|
||||
{{ statusPill.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: enter a domain -->
|
||||
<template v-if="phase === 'empty'">
|
||||
<p class="domain-copy">
|
||||
用你自己的子域名提供帮助中心,替代 ReplyPier 的 URL。在下方输入域名,我们会显示需要添加的两条 DNS 记录。
|
||||
</p>
|
||||
<div class="domain-form">
|
||||
<div class="domain-input">
|
||||
<span class="domain-input__prefix">https://</span>
|
||||
<input
|
||||
v-model="domainInput"
|
||||
type="text"
|
||||
placeholder="help.yourcompany.com"
|
||||
@keydown.enter="addDomain"
|
||||
/>
|
||||
</div>
|
||||
<button class="btn-primary" type="button" :disabled="busy || !normalizedInput" @click="addDomain">
|
||||
{{ busy ? '处理中…' : '添加域名' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: add the DNS records, then verify -->
|
||||
<template v-else>
|
||||
<div class="domain-current">
|
||||
<div class="domain-current__lead">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="M3 12h18" /><path d="M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18z" /></svg>
|
||||
<a v-if="phase === 'connected'" :href="liveUrl" target="_blank" rel="noopener" class="domain-current__name domain-current__name--link">{{ currentDomain }}</a>
|
||||
<span v-else class="domain-current__name">{{ currentDomain }}</span>
|
||||
</div>
|
||||
<button class="link-btn" type="button" :disabled="busy" @click="$emit('remove-domain')">移除</button>
|
||||
</div>
|
||||
|
||||
<template v-if="phase === 'pending'">
|
||||
<ol class="steps">
|
||||
<li>在你的 DNS 服务商处添加这两条记录。</li>
|
||||
<li>然后返回并点击 <strong>验证域名</strong>——DNS 变更可能需要几分钟生效。</li>
|
||||
</ol>
|
||||
|
||||
<div class="records">
|
||||
<div v-for="record in domain?.records || []" :key="`${record.type}-${record.host}`" class="record" :class="{ 'record--ok': record.verified }">
|
||||
<div class="record__top">
|
||||
<span class="record__type">{{ record.type }}</span>
|
||||
<span class="record-pill" :class="record.verified ? 'record-pill--teal' : 'record-pill--warn'">
|
||||
{{ record.verified ? '已检测到' : '等待中' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="record__field">
|
||||
<span class="record__label">名称 / 主机</span>
|
||||
<div class="record__value">
|
||||
<code>{{ record.host }}</code>
|
||||
<button class="copy-btn" type="button" title="复制" @click="copy(record.host)">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="record__field">
|
||||
<span class="record__label">值</span>
|
||||
<div class="record__value">
|
||||
<code>{{ record.value }}</code>
|
||||
<button class="copy-btn" type="button" title="复制" @click="copy(record.value)">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verify-row">
|
||||
<button class="btn-primary" type="button" :disabled="busy" @click="$emit('verify-domain')">
|
||||
{{ busy ? '检查中…' : '验证域名' }}
|
||||
</button>
|
||||
<span class="ssl-note">
|
||||
<span class="record-pill" :class="sslPill.cls">SSL {{ sslPill.label }}</span>
|
||||
验证通过后自动签发
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="domain-copy domain-copy--ok">
|
||||
你的帮助中心已上线:<a :href="liveUrl" target="_blank" rel="noopener">{{ currentDomain }}</a>。
|
||||
<span class="record-pill" :class="sslPill.cls">SSL {{ sslPill.label }}</span>
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.domain-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.domain-head__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.domain-copy {
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
max-width: 560px;
|
||||
line-height: 1.55;
|
||||
margin: 6px 0 16px;
|
||||
}
|
||||
|
||||
.domain-copy--ok {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.domain-copy a {
|
||||
color: var(--c-teal);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-pill__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.status-pill--teal {
|
||||
background: var(--teal-bg);
|
||||
border: 1px solid var(--teal-border);
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.status-pill--warn {
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--c-warn) 30%, transparent);
|
||||
color: var(--c-warn);
|
||||
}
|
||||
|
||||
.status-pill--idle {
|
||||
background: var(--pill-idle-bg);
|
||||
border: 1px solid var(--o12);
|
||||
color: var(--pill-idle-fg);
|
||||
}
|
||||
|
||||
.domain-form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.domain-input {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 11px;
|
||||
padding: 0 14px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.domain-input__prefix {
|
||||
color: var(--faint);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.domain-input input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
font-size: 13.5px;
|
||||
padding: 13px 4px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0 20px;
|
||||
min-height: 44px;
|
||||
background: var(--accent-solid);
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
color: var(--on-accent-solid);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* current domain header */
|
||||
.domain-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
margin: 6px 0 16px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.domain-current__lead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.domain-current__name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.domain-current__name--link {
|
||||
color: var(--c-teal);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.link-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* steps + records */
|
||||
.steps {
|
||||
margin: 0 0 14px;
|
||||
padding-left: 20px;
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.steps strong {
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.records {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.record {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 12px;
|
||||
padding: 13px 14px;
|
||||
}
|
||||
|
||||
.record--ok {
|
||||
border-color: var(--teal-border);
|
||||
}
|
||||
|
||||
.record__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.record__type {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--c-purple);
|
||||
}
|
||||
|
||||
.record__field {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.record__label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.record__value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 8px;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.record__value code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--text3);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 7px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: var(--o08);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.verify-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ssl-note {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.record-pill {
|
||||
display: inline-block;
|
||||
padding: 3px 9px;
|
||||
border-radius: 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.record-pill--teal {
|
||||
background: var(--teal-bg);
|
||||
border: 1px solid var(--teal-border);
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.record-pill--warn {
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--c-warn) 30%, transparent);
|
||||
color: var(--c-warn);
|
||||
}
|
||||
|
||||
.record-pill--coral {
|
||||
background: var(--coral-bg);
|
||||
border: 1px solid var(--coral-border);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.record-pill--idle {
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,362 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Cropper } from 'vue-advanced-cropper'
|
||||
import 'vue-advanced-cropper/dist/style.css'
|
||||
|
||||
// Generic image field (logo or favicon): file pick → square-optional crop →
|
||||
// PNG export. Configurable size caps and crop aspect so one component serves both.
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
imageUrl: string | null
|
||||
kind?: 'logo' | 'favicon'
|
||||
maxBytes?: number
|
||||
outputMax?: number
|
||||
square?: boolean
|
||||
}>(),
|
||||
{ kind: 'logo', maxBytes: 2 * 1024 * 1024, outputMax: 512, square: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
upload: [file: File]
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
// Raster only — SVG is rejected (it would be active markup on the public site).
|
||||
const ACCEPT = 'image/png,image/jpeg,image/webp'
|
||||
|
||||
const maxMbLabel = computed(() => `${Math.round(props.maxBytes / (1024 * 1024))} MB`)
|
||||
const stencilProps = computed(() => (props.square ? { aspectRatio: 1 } : {}))
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const error = ref('')
|
||||
|
||||
const showCropper = ref(false)
|
||||
const cropperImage = ref('')
|
||||
const cropper = ref<any>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
const imageName = computed(() => {
|
||||
if (!props.imageUrl) return ''
|
||||
return props.imageUrl.split('/').pop()?.split('?')[0] || props.kind
|
||||
})
|
||||
|
||||
function pick() {
|
||||
error.value = ''
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
if (!/^image\/(png|jpe?g|webp)$/.test(file.type)) {
|
||||
error.value = '请使用 PNG、JPG 或 WebP 图片。'
|
||||
return
|
||||
}
|
||||
if (file.size > props.maxBytes) {
|
||||
error.value = `图片不能超过 ${maxMbLabel.value}。`
|
||||
return
|
||||
}
|
||||
error.value = ''
|
||||
cropperImage.value = URL.createObjectURL(file)
|
||||
showCropper.value = true
|
||||
}
|
||||
|
||||
async function confirmCrop() {
|
||||
if (!cropper.value || saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const { canvas } = cropper.value.getResult()
|
||||
if (!canvas) throw new Error('no canvas')
|
||||
const blob: Blob = await new Promise((resolve, reject) => {
|
||||
canvas.toBlob((b: Blob | null) => (b ? resolve(b) : reject(new Error('blob'))), 'image/png')
|
||||
})
|
||||
emit('upload', new File([blob], `${props.kind}.png`, { type: 'image/png' }))
|
||||
closeCropper()
|
||||
} catch {
|
||||
error.value = '无法处理该图片,请换一张试试。'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeCropper() {
|
||||
showCropper.value = false
|
||||
if (cropperImage.value) URL.revokeObjectURL(cropperImage.value)
|
||||
cropperImage.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<input ref="fileInput" class="file-input" type="file" :accept="ACCEPT" @change="onFileChange" />
|
||||
<div v-if="imageUrl" class="logo-row">
|
||||
<div class="logo-chip">
|
||||
<img class="logo-chip__img" :src="imageUrl" :alt="kind" />
|
||||
<div class="logo-chip__name">{{ imageName }}</div>
|
||||
</div>
|
||||
<button class="btn-sm" type="button" @click="pick">替换</button>
|
||||
<button class="icon-btn" type="button" :title="`移除 ${kind === 'logo' ? 'Logo' : 'Favicon'}`" @click="$emit('remove')">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" /><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button v-else class="upload-btn" type="button" @click="pick">
|
||||
<span class="upload-btn__icon">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4" /><path d="M8 8l4-4 4 4" /><path d="M4 20h16" /></svg>
|
||||
</span>
|
||||
<span>
|
||||
<span class="upload-btn__title">上传 {{ kind === 'logo' ? 'Logo' : 'Favicon' }}</span>
|
||||
<span class="upload-btn__sub">PNG、JPG 或 WebP,最大 {{ maxMbLabel }} · 可裁剪适配</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p v-if="error" class="logo-error">{{ error }}</p>
|
||||
|
||||
<!-- Crop modal: the cropped canvas is exported as a fresh PNG, so nothing
|
||||
from the original file (metadata, oversized pixels) reaches the server. -->
|
||||
<div v-if="showCropper" class="crop-modal" @click.self="closeCropper">
|
||||
<div class="crop-panel">
|
||||
<div class="crop-head">裁剪 {{ kind === 'logo' ? 'Logo' : 'Favicon' }}</div>
|
||||
<div class="crop-stage">
|
||||
<Cropper
|
||||
ref="cropper"
|
||||
:src="cropperImage"
|
||||
:canvas="{ maxWidth: outputMax, maxHeight: outputMax }"
|
||||
:stencil-props="stencilProps"
|
||||
image-restriction="fit-area"
|
||||
background="var(--o05)"
|
||||
/>
|
||||
</div>
|
||||
<div class="crop-actions">
|
||||
<button class="btn-cancel" type="button" @click="closeCropper">取消</button>
|
||||
<button class="btn-save" type="button" :disabled="saving" @click="confirmCrop">
|
||||
{{ saving ? '保存中…' : `保存 ${kind === 'logo' ? 'Logo' : 'Favicon'}` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logo-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 11px;
|
||||
margin-right: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logo-chip__img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-chip__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 9px 13px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 9px;
|
||||
color: var(--text2);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: var(--o08);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
border-color: var(--coral-border);
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: var(--bg);
|
||||
border: 1.5px dashed var(--o16);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.upload-btn:hover {
|
||||
border-color: var(--o30);
|
||||
}
|
||||
|
||||
.upload-btn__icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.upload-btn__title {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.upload-btn__sub {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.logo-error {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
/* ---- crop modal ---- */
|
||||
.crop-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(10, 12, 16, 0.55);
|
||||
}
|
||||
|
||||
.crop-panel {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
background: var(--surface, var(--bg));
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 60px rgba(10, 12, 16, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.crop-head {
|
||||
padding: 16px 18px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
border-bottom: 1px solid var(--o10);
|
||||
}
|
||||
|
||||
.crop-stage {
|
||||
height: 320px;
|
||||
background: var(--o05);
|
||||
}
|
||||
|
||||
.crop-stage :deep(.vue-advanced-cropper) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.crop-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--o10);
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 10px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
padding: 10px 18px;
|
||||
background: var(--purple-bg);
|
||||
border: 1px solid var(--purple-border);
|
||||
border-radius: 10px;
|
||||
color: var(--c-purple);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-save:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,246 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { HelpCenterSettings } from '@/types/faq'
|
||||
import { contrastInk } from '@/utils/color'
|
||||
|
||||
const props = defineProps<{ settings: HelpCenterSettings }>()
|
||||
|
||||
const brand = computed(() => props.settings.brand_color || '#4338CA')
|
||||
const ink = computed(() => contrastInk(brand.value))
|
||||
|
||||
const address = computed(() => {
|
||||
const domain = props.settings.domain
|
||||
if (domain?.domain_status === 'verified' && domain.custom_domain) return domain.custom_domain
|
||||
// live_url is mode-aware (path {origin}/help/{slug} or {slug}.{base}); show it
|
||||
// without the scheme so the preview matches wherever the site is actually served.
|
||||
if (props.settings.live_url) return props.settings.live_url.replace(/^https?:\/\//, '')
|
||||
return props.settings.slug || 'your-company'
|
||||
})
|
||||
|
||||
const rootStyle = computed(() => ({ '--hc-brand': brand.value, '--hc-ink': ink.value }))
|
||||
|
||||
// Hero tint from the brand color at low alpha (1F = ~12%).
|
||||
const heroStyle = computed(() => ({
|
||||
background: `linear-gradient(180deg, ${brand.value}1F, #FFFFFF 92%)`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="preview" :style="rootStyle">
|
||||
<!-- browser chrome -->
|
||||
<div class="preview__chrome">
|
||||
<span class="preview__dot"></span><span class="preview__dot"></span><span class="preview__dot"></span>
|
||||
<div class="preview__address">{{ address }}</div>
|
||||
</div>
|
||||
<!-- site header -->
|
||||
<div class="preview__header">
|
||||
<div class="preview__brand">
|
||||
<img v-if="settings.logo_url" class="preview__logo-img" :src="settings.logo_url" alt="Logo" />
|
||||
<div v-else class="preview__logo-mark"><div class="preview__logo-diamond"></div></div>
|
||||
<span class="preview__brand-name">{{ settings.title || '你的公司' }}</span>
|
||||
</div>
|
||||
<div class="preview__nav">
|
||||
<span v-for="link in settings.header_links" :key="link.label" class="preview__nav-link">{{ link.label }}</span>
|
||||
<span v-if="settings.cta_enabled && settings.cta_text" class="preview__cta">{{ settings.cta_text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- hero -->
|
||||
<div class="preview__hero" :style="heroStyle">
|
||||
<div class="preview__hero-title">有什么可以帮你?</div>
|
||||
<div class="preview__search">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#9AA0AD" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
|
||||
<span class="preview__search-hint">搜索答案…</span>
|
||||
<span class="preview__search-btn">搜索</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* This component simulates the visitor-facing help center, an external
|
||||
light-mode website — the fixed light palette below is deliberate and must
|
||||
NOT follow the admin app's theme tokens. */
|
||||
.preview {
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--o10);
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.preview__chrome {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 13px;
|
||||
background: #ebedf1;
|
||||
border-bottom: 1px solid #dde0e7;
|
||||
}
|
||||
|
||||
.preview__dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #c6cad3;
|
||||
}
|
||||
|
||||
.preview__address {
|
||||
margin-left: 9px;
|
||||
flex: 1;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 11px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: #9aa0ad;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.preview__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #ecedf2;
|
||||
}
|
||||
|
||||
.preview__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview__logo-img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview__logo-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--hc-brand);
|
||||
}
|
||||
|
||||
.preview__logo-diamond {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 3px;
|
||||
background: var(--hc-ink);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.preview__brand-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: #1a1b25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.preview__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview__nav-link {
|
||||
font-size: 12.5px;
|
||||
color: #5a6172;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 15px;
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
background: var(--hc-brand);
|
||||
color: var(--hc-ink);
|
||||
}
|
||||
|
||||
.preview__hero {
|
||||
padding: 22px 20px 20px;
|
||||
}
|
||||
|
||||
.preview__hero-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
color: #1a1b25;
|
||||
text-align: center;
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
|
||||
.preview__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e4e6ec;
|
||||
border-radius: 11px;
|
||||
padding: 5px 5px 5px 13px;
|
||||
max-width: 340px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 8px 20px rgba(40, 36, 90, 0.08);
|
||||
}
|
||||
|
||||
.preview__search svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview__search-hint {
|
||||
flex: 1;
|
||||
font-size: 12.5px;
|
||||
color: #9aa0ad;
|
||||
}
|
||||
|
||||
.preview__search-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 9px 16px;
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
background: var(--hc-brand);
|
||||
color: var(--hc-ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,316 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HelpCenterSettings } from '@/types/faq'
|
||||
import HelpCenterDomainPanel from './HelpCenterDomainPanel.vue'
|
||||
|
||||
defineProps<{
|
||||
settings: HelpCenterSettings
|
||||
domainBusy: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'toggle-enabled': [enabled: boolean]
|
||||
'update-ai': [payload: { agent_id?: string | null; ai_search_enabled?: boolean; chat_widget_enabled?: boolean }]
|
||||
'set-domain': [domain: string]
|
||||
'verify-domain': []
|
||||
'remove-domain': []
|
||||
}>()
|
||||
|
||||
function onAgentChange(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
emit('update-ai', { agent_id: value || null })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="public">
|
||||
<h2 class="public__heading">公开帮助中心</h2>
|
||||
|
||||
<div class="public__card">
|
||||
<!-- publish row -->
|
||||
<div class="publish-row">
|
||||
<div class="publish-row__lead">
|
||||
<label class="switch">
|
||||
<input type="checkbox" :checked="settings.enabled" @change="emit('toggle-enabled', ($event.target as HTMLInputElement).checked)" />
|
||||
<span class="switch__track"><span class="switch__knob"></span></span>
|
||||
</label>
|
||||
<span class="status-pill" :class="settings.enabled ? 'status-pill--teal' : 'status-pill--idle'">
|
||||
<span class="status-pill__dot" :class="{ 'status-pill__dot--glow': settings.enabled }"></span>
|
||||
{{ settings.enabled ? '已发布' : '草稿' }}
|
||||
</span>
|
||||
<span class="publish-row__text">已发布 {{ settings.published_count }} 条常见问题,地址:</span>
|
||||
<a v-if="settings.live_url" class="publish-row__url" :href="settings.live_url" target="_blank" rel="noopener">{{ settings.live_url }}</a>
|
||||
</div>
|
||||
<a v-if="settings.live_url" class="btn-ghost" :href="settings.live_url" target="_blank" rel="noopener">打开帮助中心 →</a>
|
||||
</div>
|
||||
|
||||
<!-- AI agent + surfaces -->
|
||||
<div class="block">
|
||||
<label class="mono-label">AI 智能体</label>
|
||||
<div class="ai-row">
|
||||
<select class="agent-select" :value="settings.agent_id || ''" @change="onAgentChange">
|
||||
<option value="">选择智能体…</option>
|
||||
<option v-for="agent in settings.agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="toggle-list">
|
||||
<label class="switch switch--labelled">
|
||||
<input type="checkbox" :checked="settings.ai_search_enabled" @change="emit('update-ai', { ai_search_enabled: ($event.target as HTMLInputElement).checked })" />
|
||||
<span class="switch__track"><span class="switch__knob"></span></span>
|
||||
<span class="switch__label">
|
||||
搜索中的 AI 快速摘要
|
||||
<span class="switch__sub">在搜索结果上方即时给出 AI 回答,基于该智能体的知识。</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="switch switch--labelled">
|
||||
<input type="checkbox" :checked="settings.chat_widget_enabled" @change="emit('update-ai', { chat_widget_enabled: ($event.target as HTMLInputElement).checked })" />
|
||||
<span class="switch__track"><span class="switch__knob"></span></span>
|
||||
<span class="switch__label">
|
||||
聊天挂件
|
||||
<span class="switch__sub">嵌入该智能体的聊天挂件,让访客可以直接发起对话。</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">两种展示都使用上方选择的智能体——可以只选一个、两个都选,或都不选。</p>
|
||||
</div>
|
||||
|
||||
<!-- custom domain -->
|
||||
<div class="block">
|
||||
<HelpCenterDomainPanel
|
||||
:domain="settings.domain"
|
||||
:busy="domainBusy"
|
||||
@set-domain="emit('set-domain', $event)"
|
||||
@verify-domain="emit('verify-domain')"
|
||||
@remove-domain="emit('remove-domain')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.public__heading {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 19px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.public__card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.publish-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.publish-row__lead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.publish-row__text {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.publish-row__url {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--accent-ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: 10px;
|
||||
color: var(--text2);
|
||||
text-decoration: none;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--o08);
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.switch__track {
|
||||
flex-shrink: 0;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--toggle-track-off);
|
||||
padding: 2px;
|
||||
display: inline-flex;
|
||||
transition: background-color var(--transition-fast);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.switch input:checked + .switch__track {
|
||||
background: var(--toggle-on-teal);
|
||||
}
|
||||
|
||||
.switch__knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--toggle-knob);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.switch input:checked + .switch__track .switch__knob {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.switch__label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.switch__sub {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text3, var(--text2));
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.toggle-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.switch--labelled {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-pill__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.status-pill__dot--glow {
|
||||
box-shadow: 0 0 6px var(--c-teal);
|
||||
}
|
||||
|
||||
.status-pill--teal {
|
||||
background: var(--teal-bg);
|
||||
border: 1px solid var(--teal-border);
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.status-pill--idle {
|
||||
background: var(--pill-idle-bg);
|
||||
border: 1px solid var(--o12);
|
||||
color: var(--pill-idle-fg);
|
||||
}
|
||||
|
||||
.block {
|
||||
padding: 22px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
}
|
||||
|
||||
.block:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mono-label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ai-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.agent-select {
|
||||
min-width: 220px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
margin: 12px 0 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,266 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { faqService } from '@/services/faq'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ modelValue: string; placeholder?: string }>(),
|
||||
{ placeholder: '回答——支持 Markdown(标题、**加粗**、列表、链接、图片)' },
|
||||
)
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
const textarea = ref<HTMLTextAreaElement | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const mode = ref<'write' | 'preview'>('write')
|
||||
const uploading = ref(false)
|
||||
|
||||
// Preview renders in a sandboxed iframe (no allow-scripts): it safely shows
|
||||
// images/links from the author's Markdown without executing anything, and
|
||||
// sidesteps the app-wide DOMPurify hook that strips <img> elsewhere.
|
||||
const previewDoc = computed(() => {
|
||||
const body = props.modelValue.trim()
|
||||
? (marked.parse(props.modelValue, { breaks: true, async: false }) as string)
|
||||
: '<p style="color:#9aa0ad">暂无可预览的内容。</p>'
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
body{font-family:system-ui,-apple-system,sans-serif;font-size:14px;line-height:1.7;color:#2a303e;margin:14px;}
|
||||
h1,h2,h3,h4{font-weight:700;line-height:1.3;margin:20px 0 10px;color:#1a1b25;}
|
||||
h1{font-size:20px}h2{font-size:17px}h3{font-size:15px}
|
||||
a{color:#4338ca;font-weight:600}
|
||||
img{max-width:100%;height:auto;border-radius:10px;border:1px solid #ececf2;margin:6px 0}
|
||||
ul,ol{padding-left:22px}li{margin:4px 0}
|
||||
blockquote{margin:6px 0;padding:10px 14px;background:#f4f2fd;border-left:3px solid #6d5bd0;border-radius:8px}
|
||||
code{font-family:ui-monospace,monospace;background:#f1f2f6;border-radius:4px;padding:1px 5px}
|
||||
pre{background:#f1f2f6;border-radius:10px;padding:12px;overflow-x:auto}
|
||||
pre code{background:none;padding:0}
|
||||
table{border-collapse:collapse;width:100%}th,td{border:1px solid #ececf2;padding:6px 9px}
|
||||
</style></head><body>${body}</body></html>`
|
||||
})
|
||||
|
||||
/** Replace the current selection (or insert at caret), then restore focus/caret. */
|
||||
function replaceSelection(transform: (selected: string) => { text: string; caretOffset?: number }) {
|
||||
const el = textarea.value
|
||||
if (!el) return
|
||||
const start = el.selectionStart
|
||||
const end = el.selectionEnd
|
||||
const value = props.modelValue
|
||||
const selected = value.slice(start, end)
|
||||
const { text, caretOffset } = transform(selected)
|
||||
const next = value.slice(0, start) + text + value.slice(end)
|
||||
emit('update:modelValue', next)
|
||||
const caret = caretOffset === undefined ? start + text.length : start + caretOffset
|
||||
nextTick(() => {
|
||||
el.focus()
|
||||
el.setSelectionRange(caret, caret)
|
||||
})
|
||||
}
|
||||
|
||||
function wrap(before: string, after: string, placeholder: string) {
|
||||
replaceSelection((sel) => {
|
||||
const inner = sel || placeholder
|
||||
return { text: `${before}${inner}${after}`, caretOffset: before.length + inner.length }
|
||||
})
|
||||
}
|
||||
|
||||
function prefixLine(prefix: string, placeholder: string) {
|
||||
replaceSelection((sel) => {
|
||||
const inner = sel || placeholder
|
||||
return { text: `${prefix}${inner}`, caretOffset: prefix.length + inner.length }
|
||||
})
|
||||
}
|
||||
|
||||
function insertLink() {
|
||||
replaceSelection((sel) => {
|
||||
const label = sel || '链接文字'
|
||||
return { text: `[${label}](https://)`, caretOffset: label.length + 3 }
|
||||
})
|
||||
}
|
||||
|
||||
async function onFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const url = await faqService.uploadImage(file)
|
||||
const alt = file.name.replace(/\.[^.]+$/, '')
|
||||
replaceSelection(() => ({ text: `\n\n` }))
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '图片上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="md-editor">
|
||||
<div class="md-toolbar">
|
||||
<div class="md-tools">
|
||||
<button type="button" class="md-btn" title="标题" @click="prefixLine('## ', '标题')">H</button>
|
||||
<button type="button" class="md-btn md-btn--bold" title="加粗" @click="wrap('**', '**', '加粗')">B</button>
|
||||
<button type="button" class="md-btn" title="无序列表" @click="prefixLine('- ', '列表项')">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="20" y2="12"/><line x1="8" y1="18" x2="20" y2="18"/><circle cx="3.5" cy="6" r="1"/><circle cx="3.5" cy="12" r="1"/><circle cx="3.5" cy="18" r="1"/></svg>
|
||||
</button>
|
||||
<button type="button" class="md-btn" title="链接" @click="insertLink">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/></svg>
|
||||
</button>
|
||||
<button type="button" class="md-btn" title="插入图片" :disabled="uploading" @click="fileInput?.click()">
|
||||
<svg v-if="!uploading" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
|
||||
<span v-else class="md-spin"></span>
|
||||
</button>
|
||||
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif,image/webp" hidden @change="onFile" />
|
||||
</div>
|
||||
<div class="md-modes">
|
||||
<button type="button" class="md-mode" :class="{ 'md-mode--on': mode === 'write' }" @click="mode = 'write'">编写</button>
|
||||
<button type="button" class="md-mode" :class="{ 'md-mode--on': mode === 'preview' }" @click="mode = 'preview'">预览</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-show="mode === 'write'"
|
||||
ref="textarea"
|
||||
class="md-textarea"
|
||||
rows="8"
|
||||
:placeholder="placeholder"
|
||||
:value="modelValue"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
></textarea>
|
||||
|
||||
<iframe v-show="mode === 'preview'" class="md-preview" :srcdoc="previewDoc" sandbox="" title="回答预览"></iframe>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.md-editor {
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.md-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--o08);
|
||||
background: var(--o03);
|
||||
}
|
||||
|
||||
.md-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.md-btn {
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0 7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.md-btn:hover {
|
||||
background: var(--o08);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.md-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.md-btn--bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.md-modes {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--o05);
|
||||
border-radius: 8px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.md-mode {
|
||||
padding: 5px 11px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.md-mode--on {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.md-textarea {
|
||||
width: 100%;
|
||||
display: block;
|
||||
background: var(--bg);
|
||||
border: none;
|
||||
padding: 12px 13px;
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.md-preview {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
border: none;
|
||||
background: #fff;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.md-spin {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid var(--o16);
|
||||
border-top-color: var(--c-purple);
|
||||
border-radius: 50%;
|
||||
animation: md-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes md-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,342 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import channelsService, { type ChannelAccount, type SmsProviderInfo } from '@/services/channels'
|
||||
import { agentService } from '@/services/agent'
|
||||
import type { Agent } from '@/types/agent'
|
||||
import CopyField from './CopyField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
channel: 'email' | 'sms' | 'line' | 'slack'
|
||||
// When set, the modal opens in "manage" mode for an already-connected
|
||||
// account: it skips credential entry and shows the webhook URL + agent.
|
||||
// Slack always uses this (it connects via OAuth, so only the agent step).
|
||||
existingAccount?: ChannelAccount | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'connected', account: ChannelAccount): void
|
||||
}>()
|
||||
|
||||
const FORMS = {
|
||||
email: {
|
||||
title: '连接 Email',
|
||||
intro: '填写客户来信的客服邮箱地址,然后将服务商的入站解析 / 转发 Webhook 指向下方显示的 URL。可选:配置自己的发信 SMTP,以便使用自有域名发送回复并正确设置 SPF/DKIM(留空则使用平台邮件服务器)。',
|
||||
fields: [
|
||||
{ key: 'inbound_address', label: '客服邮箱地址', placeholder: 'support@yourcompany.com', secret: false },
|
||||
{ key: 'smtp_host', label: 'SMTP 主机(可选)', placeholder: 'smtp.yourprovider.com', secret: false, optional: true },
|
||||
{ key: 'smtp_port', label: 'SMTP 端口(可选)', placeholder: '587', secret: false, optional: true },
|
||||
{ key: 'smtp_username', label: 'SMTP 用户名(可选)', placeholder: 'apikey / user', secret: false, optional: true },
|
||||
{ key: 'smtp_password', label: 'SMTP 密码(可选)', placeholder: '••••••••', secret: true, optional: true },
|
||||
{ key: 'from_email', label: '发件地址(可选)', placeholder: '默认使用客服邮箱地址', secret: false, optional: true },
|
||||
],
|
||||
connect: (v: Record<string, string>) => {
|
||||
const payload: any = { inbound_address: v.inbound_address }
|
||||
if (v.smtp_host?.trim()) {
|
||||
payload.smtp_host = v.smtp_host.trim()
|
||||
if (v.smtp_port?.trim()) payload.smtp_port = Number(v.smtp_port)
|
||||
payload.smtp_username = v.smtp_username
|
||||
payload.smtp_password = v.smtp_password
|
||||
if (v.from_email?.trim()) payload.from_email = v.from_email.trim()
|
||||
}
|
||||
return channelsService.connectEmail(payload)
|
||||
},
|
||||
},
|
||||
sms: {
|
||||
title: '连接 SMS',
|
||||
intro: '选择短信服务商,填写发信号码及其 API 凭据。连接完成后,将显示的 URL 设置为该号码的入站消息 Webhook(SNS 使用 HTTPS 主题订阅)。',
|
||||
// Fields are provider-specific and resolved dynamically (see activeFields)
|
||||
fields: [],
|
||||
connect: (v: Record<string, string>) => {
|
||||
const info = smsProviders.value.find(p => p.name === selectedProvider.value)
|
||||
const credentials: Record<string, string> = {}
|
||||
for (const f of info?.fields || []) {
|
||||
if (v[f.key]?.trim()) credentials[f.key] = v[f.key].trim()
|
||||
}
|
||||
return channelsService.connectSms({
|
||||
provider: selectedProvider.value,
|
||||
phone_number: v.phone_number,
|
||||
credentials,
|
||||
})
|
||||
},
|
||||
},
|
||||
line: {
|
||||
title: '连接 LINE',
|
||||
intro: '在 LINE Developers 控制台(Messaging API 渠道)中复制 Channel secret 并签发 Channel access token。Webhook 会自动配置。',
|
||||
fields: [
|
||||
{ key: 'channel_secret', label: 'Channel secret', placeholder: '••••••••', secret: true },
|
||||
{ key: 'channel_access_token', label: 'Channel access token', placeholder: '长期有效 Token', secret: true },
|
||||
],
|
||||
connect: (v: Record<string, string>) => channelsService.connectLine({
|
||||
channel_secret: v.channel_secret, channel_access_token: v.channel_access_token }),
|
||||
},
|
||||
slack: {
|
||||
// Slack connects via OAuth (no credential form); this modal is only used
|
||||
// in manage mode to pick the answering agent.
|
||||
title: 'Slack',
|
||||
intro: '选择由哪个 AI 智能体应答 Slack 中的提及和私信。',
|
||||
fields: [],
|
||||
connect: async () => { throw new Error('Slack connects via OAuth') },
|
||||
},
|
||||
} as const
|
||||
|
||||
const form = computed(() => FORMS[props.channel])
|
||||
const values = ref<Record<string, string>>({})
|
||||
const connecting = ref(false)
|
||||
// In manage mode we start at the second step with the existing account
|
||||
const account = ref<ChannelAccount | null>(props.existingAccount ?? null)
|
||||
const isManage = computed(() => !!props.existingAccount)
|
||||
|
||||
// SMS providers (dynamic credential fields per provider)
|
||||
const smsProviders = ref<SmsProviderInfo[]>([])
|
||||
const selectedProvider = ref('twilio')
|
||||
|
||||
// The credential fields to render: dynamic for SMS, static otherwise
|
||||
const activeFields = computed(() => {
|
||||
if (props.channel !== 'sms') return form.value.fields
|
||||
const info = smsProviders.value.find(p => p.name === selectedProvider.value)
|
||||
return [
|
||||
{ key: 'phone_number', label: '电话号码 / 发送方 ID', placeholder: '+15551234567', secret: false },
|
||||
...(info?.fields || []).map(f => ({
|
||||
key: f.key, label: f.label, placeholder: f.secret ? '••••••••' : '',
|
||||
secret: f.secret, optional: f.optional,
|
||||
})),
|
||||
]
|
||||
})
|
||||
|
||||
const agents = ref<Agent[]>([])
|
||||
const selectedAgentId = ref('')
|
||||
const savingAgent = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (props.channel === 'sms') {
|
||||
smsProviders.value = await channelsService.listSmsProviders()
|
||||
}
|
||||
agents.value = await agentService.getOrganizationAgents()
|
||||
// Default the agent selector to the account's current agent, else the first
|
||||
selectedAgentId.value = String(
|
||||
props.existingAccount?.agent_id || agents.value[0]?.id || '')
|
||||
} catch (error) {
|
||||
console.error('Error loading modal data:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const connect = async () => {
|
||||
const missing = activeFields.value.filter(f => !(f as any).optional && !values.value[f.key]?.trim())
|
||||
if (missing.length > 0) {
|
||||
toast.error(`请填写:${missing.map(f => f.label).join('、')}`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
connecting.value = true
|
||||
const trimmed = Object.fromEntries(
|
||||
Object.entries(values.value).map(([k, v]) => [k, v.trim()]))
|
||||
account.value = await form.value.connect(trimmed)
|
||||
toast.success(`已连接 ${account.value.display_name || form.value.title.replace('连接 ', '')}`)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.detail || `连接 ${props.channel} 失败`)
|
||||
} finally {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveAgent = async () => {
|
||||
if (!account.value || !selectedAgentId.value) return
|
||||
try {
|
||||
savingAgent.value = true
|
||||
const updated = await channelsService.setAccountAgent(account.value.id, selectedAgentId.value)
|
||||
toast.success('智能体已分配 — 该渠道已生效!')
|
||||
emit('connected', updated)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.detail || '分配智能体失败')
|
||||
} finally {
|
||||
savingAgent.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Intentionally no backdrop-click dismiss: these forms hold credentials
|
||||
the user is mid-entry on, so only the × / Cancel buttons close it. -->
|
||||
<div class="cc-modal">
|
||||
<div class="cc-modal-content">
|
||||
<div class="cc-modal-header">
|
||||
<h3>{{ isManage ? form.title.replace('连接', '管理') : form.title }}</h3>
|
||||
<button class="cc-close-btn" @click="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: credentials -->
|
||||
<div v-if="!account" class="cc-modal-body">
|
||||
<p class="cc-intro">{{ form.intro }}</p>
|
||||
|
||||
<!-- SMS provider picker -->
|
||||
<div v-if="channel === 'sms'" class="cc-field">
|
||||
<label class="cc-label" for="cc-provider">短信服务商</label>
|
||||
<select id="cc-provider" v-model="selectedProvider" class="cc-input">
|
||||
<option v-for="p in smsProviders" :key="p.name" :value="p.name">{{ p.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-for="field in activeFields" :key="field.key" class="cc-field">
|
||||
<label class="cc-label" :for="`cc-${field.key}`">{{ field.label }}</label>
|
||||
<input
|
||||
:id="`cc-${field.key}`"
|
||||
v-model="values[field.key]"
|
||||
:type="field.secret ? 'password' : 'text'"
|
||||
class="cc-input"
|
||||
:placeholder="(field as any).placeholder"
|
||||
:name="`cc-${channel}-${field.key}`"
|
||||
:autocomplete="field.secret ? 'new-password' : 'off'"
|
||||
/>
|
||||
</div>
|
||||
<div class="cc-actions">
|
||||
<button class="cc-btn cc-btn-secondary" @click="emit('close')">取消</button>
|
||||
<button class="cc-btn cc-btn-primary" :disabled="connecting" @click="connect">
|
||||
{{ connecting ? '连接中…' : '连接' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: webhook URL (if applicable) + agent routing -->
|
||||
<div v-else class="cc-modal-body">
|
||||
<p class="cc-intro">
|
||||
<strong>{{ account.display_name }}</strong> 已连接。
|
||||
</p>
|
||||
<CopyField v-if="account.webhook_url" class="cc-field" field-id="cc-webhook-url"
|
||||
label="Webhook URL — 在你的服务商处设置" name="Webhook URL"
|
||||
:value="account.webhook_url" />
|
||||
<label class="cc-label" for="cc-agent">负责应答该渠道的 AI 智能体</label>
|
||||
<select id="cc-agent" v-model="selectedAgentId" class="cc-input">
|
||||
<option v-for="agent in agents" :key="String(agent.id)" :value="String(agent.id)">
|
||||
{{ agent.display_name || agent.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="cc-actions">
|
||||
<button v-if="isManage && channel !== 'slack'" class="cc-btn cc-btn-secondary" @click="account = null">
|
||||
重新配置凭据
|
||||
</button>
|
||||
<button v-else class="cc-btn cc-btn-secondary" @click="emit('connected', account)">暂时跳过</button>
|
||||
<button class="cc-btn cc-btn-primary" :disabled="savingAgent || !selectedAgentId" @click="saveAgent">
|
||||
{{ savingAgent ? '保存中…' : '分配智能体' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cc-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.cc-modal-content {
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.cc-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cc-modal-header h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.cc-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cc-intro {
|
||||
margin: 0 0 16px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cc-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cc-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.cc-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
background: var(--background-soft);
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cc-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.cc-btn {
|
||||
padding: 9px 16px;
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--background-soft);
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cc-btn-primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.cc-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<!-- A read-only value the customer copies into another product's settings
|
||||
(a webhook URL, a verify token). -->
|
||||
<script setup lang="ts">
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const props = defineProps<{
|
||||
fieldId: string
|
||||
label: string
|
||||
/** What the toast calls it ("Webhook URL copied") */
|
||||
name: string
|
||||
value: string
|
||||
}>()
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.value)
|
||||
toast.success(`${props.name}已复制`)
|
||||
} catch {
|
||||
// No clipboard on plain-http origins: the field stays selectable
|
||||
toast.error('复制失败,请选中文本后手动复制。')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="copy-field">
|
||||
<label class="copy-label" :for="fieldId">{{ label }}</label>
|
||||
<div class="copy-row">
|
||||
<input :id="fieldId" class="copy-input" :value="value" readonly />
|
||||
<button class="copy-btn" type="button" @click="copy">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.copy-field + .copy-field {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.copy-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.copy-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.copy-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
background: var(--background-soft);
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
background: var(--background-soft);
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.copy-btn:focus-visible,
|
||||
.copy-input:focus-visible {
|
||||
outline: 2px solid var(--accent-ink);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<!--
|
||||
Facebook Login for Business can grant several Pages at once, so the customer
|
||||
chooses which one this channel answers. WhatsApp Embedded Signup never needs
|
||||
this — it creates the single number itself.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { MessengerSignupPage } from '@/services/channels'
|
||||
|
||||
defineProps<{
|
||||
pages: MessengerSignupPage[]
|
||||
connecting: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', pageId: string): void
|
||||
}>()
|
||||
|
||||
// Which row is being connected, so only it shows a spinner (and the rest lock).
|
||||
const pendingId = ref('')
|
||||
|
||||
const choose = (pageId: string) => {
|
||||
pendingId.value = pageId
|
||||
emit('select', pageId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="picker">
|
||||
<p class="picker-intro">选择要连接的账号:</p>
|
||||
<ul class="picker-list">
|
||||
<li v-for="page in pages" :key="page.id">
|
||||
<button
|
||||
type="button"
|
||||
class="picker-row"
|
||||
:disabled="connecting"
|
||||
@click="choose(page.id)"
|
||||
>
|
||||
<span class="picker-name">{{ page.name }}</span>
|
||||
<font-awesome-icon
|
||||
v-if="connecting && pendingId === page.id"
|
||||
icon="fa-solid fa-spinner"
|
||||
spin
|
||||
/>
|
||||
<font-awesome-icon v-else icon="fa-solid fa-chevron-right" class="picker-chevron" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.picker-intro {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.picker-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.picker-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
background: var(--background-soft);
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.picker-row:hover:not(:disabled) {
|
||||
border-color: var(--accent-solid);
|
||||
}
|
||||
|
||||
.picker-row:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.picker-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picker-chevron {
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,402 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import channelsService, { type ChannelAccount } from '@/services/channels'
|
||||
import { agentService } from '@/services/agent'
|
||||
import type { Agent } from '@/types/agent'
|
||||
import MessengerPagePicker from './MessengerPagePicker.vue'
|
||||
import MetaOwnAppWebhook from './MetaOwnAppWebhook.vue'
|
||||
import { useMetaSignup } from '@/composables/useMetaSignup'
|
||||
|
||||
const props = defineProps<{
|
||||
channel: 'whatsapp' | 'messenger' | 'instagram'
|
||||
existingAccount?: ChannelAccount | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'connected', account: ChannelAccount): void
|
||||
}>()
|
||||
|
||||
// Where the credentials are copied from — linked so it's one click, not a
|
||||
// hostname to retype.
|
||||
const META_APPS_URL = 'https://developers.facebook.com/apps/'
|
||||
|
||||
// Meta signs your own app's webhooks with its secret: without it a number or
|
||||
// Page from your app can send but never receives.
|
||||
const APP_SECRET_FIELD = {
|
||||
key: 'app_secret', label: 'App Secret(可选)', placeholder: 'App settings → Basic', secret: true,
|
||||
} as const
|
||||
|
||||
// Per-channel copy + credential fields. The intro is split around the link
|
||||
// rather than carrying markup, so it renders as escaped text.
|
||||
const META_FORMS = {
|
||||
whatsapp: {
|
||||
title: '连接 WhatsApp',
|
||||
introBefore: '在你的 Meta 应用中(',
|
||||
introAfter: ' → WhatsApp → API Setup)复制电话号码 ID 和永久访问 Token。添加 App Secret 后才能接收消息。',
|
||||
fields: [
|
||||
{ key: 'phone_number_id', label: '电话号码 ID', placeholder: '1234567890', secret: false },
|
||||
{ key: 'access_token', label: '访问 Token', placeholder: 'EAAG…', secret: true },
|
||||
{ key: 'waba_id', label: 'WhatsApp Business 账号 ID(可选)', placeholder: '用于自动订阅 Webhook', secret: false },
|
||||
APP_SECRET_FIELD,
|
||||
],
|
||||
},
|
||||
messenger: {
|
||||
title: '连接 Messenger',
|
||||
introBefore: '在你的 Meta 应用中(',
|
||||
introAfter: ' → Messenger → 设置)为要连接的 Facebook 公共主页生成公共主页访问 Token。添加 App Secret 后才能接收消息。',
|
||||
fields: [
|
||||
{ key: 'page_id', label: 'Facebook 公共主页 ID', placeholder: '1234567890', secret: false },
|
||||
{ key: 'page_access_token', label: '公共主页访问 Token', placeholder: 'EAAG…', secret: true },
|
||||
APP_SECRET_FIELD,
|
||||
],
|
||||
},
|
||||
instagram: {
|
||||
title: '连接 Instagram',
|
||||
introBefore: '你的 Instagram 账号必须是已关联 Facebook 公共主页的专业账号。请使用所关联公共主页的访问 Token,来源:',
|
||||
introAfter: ' → Instagram。',
|
||||
fields: [
|
||||
{ key: 'ig_id', label: 'Instagram 账号 ID', placeholder: '17841400000000000', secret: false },
|
||||
{ key: 'page_access_token', label: '关联公共主页访问 Token', placeholder: 'EAAG…', secret: true },
|
||||
],
|
||||
},
|
||||
} as const
|
||||
|
||||
const form = computed(() => META_FORMS[props.channel])
|
||||
const values = ref<Record<string, string>>({})
|
||||
const connecting = ref(false)
|
||||
const account = ref<ChannelAccount | null>(props.existingAccount ?? null)
|
||||
|
||||
const agents = ref<Agent[]>([])
|
||||
const selectedAgentId = ref('')
|
||||
const savingAgent = ref(false)
|
||||
|
||||
// The three one-click logins live in their own composable; this component owns
|
||||
// the manual credentials form and agent assignment.
|
||||
const {
|
||||
signupEnabled,
|
||||
signingUp,
|
||||
showManualForm,
|
||||
signupPages,
|
||||
connectingPage,
|
||||
copy: signupCopy,
|
||||
startSignup,
|
||||
onPageSelected,
|
||||
} = useMetaSignup({
|
||||
channel: props.channel,
|
||||
existingAccount: props.existingAccount,
|
||||
onConnected: (connected) => { account.value = connected },
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
agents.value = await agentService.getOrganizationAgents()
|
||||
selectedAgentId.value = String(
|
||||
props.existingAccount?.agent_id || agents.value[0]?.id || '')
|
||||
} catch (error) {
|
||||
console.error('Error loading agents:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const connect = async () => {
|
||||
const missing = form.value.fields.filter(f => !f.label.includes('可选') && !values.value[f.key]?.trim())
|
||||
if (missing.length > 0) {
|
||||
toast.error(`请填写:${missing.map(f => f.label).join('、')}`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
connecting.value = true
|
||||
const payload: any = Object.fromEntries(
|
||||
form.value.fields
|
||||
.map(f => [f.key, values.value[f.key]?.trim()])
|
||||
.filter(([, v]) => v)
|
||||
)
|
||||
if (props.channel === 'whatsapp') {
|
||||
account.value = await channelsService.connectWhatsApp(payload)
|
||||
} else if (props.channel === 'messenger') {
|
||||
account.value = await channelsService.connectMessenger(payload)
|
||||
} else {
|
||||
account.value = await channelsService.connectInstagram(payload)
|
||||
}
|
||||
toast.success(`已连接 ${account.value.display_name || form.value.title.replace('连接 ', '')}`)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.detail || `连接 ${props.channel} 失败`)
|
||||
} finally {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveAgent = async () => {
|
||||
if (!account.value || !selectedAgentId.value) return
|
||||
try {
|
||||
savingAgent.value = true
|
||||
const updated = await channelsService.setAccountAgent(account.value.id, selectedAgentId.value)
|
||||
toast.success('智能体已分配 — 该渠道已生效!')
|
||||
emit('connected', updated)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.detail || '分配智能体失败')
|
||||
} finally {
|
||||
savingAgent.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="meta-modal" @click.self="emit('close')">
|
||||
<div class="meta-modal-content">
|
||||
<div class="meta-modal-header">
|
||||
<h3>{{ form.title }}</h3>
|
||||
<button class="meta-close-btn" @click="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: credentials -->
|
||||
<div v-if="!account" class="meta-modal-body">
|
||||
<!-- Login for Business can grant several accounts at once; the customer
|
||||
picks which one this channel answers. -->
|
||||
<MessengerPagePicker
|
||||
v-if="signupPages.length"
|
||||
:pages="signupPages"
|
||||
:connecting="connectingPage"
|
||||
@select="onPageSelected"
|
||||
/>
|
||||
|
||||
<!-- One-click signup under ReplyPier's Meta app; the manual form
|
||||
stays available for anyone who already has their own credentials. -->
|
||||
<div v-else-if="signupEnabled && !showManualForm" class="meta-signup">
|
||||
<p class="meta-intro">{{ signupCopy.intro }}</p>
|
||||
<button class="meta-btn meta-btn-primary meta-signup-btn" :disabled="signingUp" @click="startSignup">
|
||||
<font-awesome-icon v-if="signingUp" icon="fa-solid fa-spinner" spin />
|
||||
{{ signingUp ? '正在等待 Meta…' : signupCopy.cta }}
|
||||
</button>
|
||||
<button class="meta-link-btn" @click="showManualForm = true">
|
||||
改为手动输入凭据
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<p class="meta-intro">
|
||||
{{ form.introBefore }}<a
|
||||
:href="META_APPS_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="meta-intro-link"
|
||||
>developers.facebook.com</a>{{ form.introAfter }}
|
||||
</p>
|
||||
<div v-for="field in form.fields" :key="field.key" class="meta-field">
|
||||
<label class="meta-label" :for="`meta-${field.key}`">{{ field.label }}</label>
|
||||
<input
|
||||
:id="`meta-${field.key}`"
|
||||
v-model="values[field.key]"
|
||||
:type="field.secret ? 'password' : 'text'"
|
||||
class="meta-input"
|
||||
:placeholder="field.placeholder"
|
||||
:name="`meta-${channel}-${field.key}`"
|
||||
:autocomplete="field.secret ? 'new-password' : 'off'"
|
||||
/>
|
||||
</div>
|
||||
<div class="meta-actions">
|
||||
<button class="meta-btn meta-btn-secondary" @click="emit('close')">取消</button>
|
||||
<button class="meta-btn meta-btn-primary" :disabled="connecting" @click="connect">
|
||||
{{ connecting ? '连接中…' : '连接' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: route to an agent -->
|
||||
<div v-else class="meta-modal-body">
|
||||
<div v-if="account.needs_reconnect" class="meta-reconnect">
|
||||
<p class="meta-reconnect-text">Meta 已停止接受该账号的访问权限,请重新连接以恢复回复。</p>
|
||||
<button class="meta-btn meta-btn-primary" @click="account = null">重新连接</button>
|
||||
</div>
|
||||
<p class="meta-intro">
|
||||
<strong>{{ account.display_name }}</strong> 已连接。
|
||||
</p>
|
||||
<MetaOwnAppWebhook v-if="account.webhook_url && account.webhook_verify_token" :account="account" />
|
||||
<p class="meta-intro">选择由哪个 AI 智能体应答其消息:</p>
|
||||
<label class="meta-label" for="meta-agent">AI 智能体</label>
|
||||
<select id="meta-agent" v-model="selectedAgentId" class="meta-input">
|
||||
<option v-for="agent in agents" :key="String(agent.id)" :value="String(agent.id)">
|
||||
{{ agent.display_name || agent.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="meta-actions">
|
||||
<button class="meta-btn meta-btn-secondary" @click="emit('connected', account)">暂时跳过</button>
|
||||
<button class="meta-btn meta-btn-primary" :disabled="savingAgent || !selectedAgentId" @click="saveAgent">
|
||||
{{ savingAgent ? '保存中…' : '分配智能体' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.meta-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.meta-modal-content {
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
width: min(460px, calc(100vw - 32px));
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.meta-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.meta-modal-header h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.meta-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.meta-reconnect {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 0 16px;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
.meta-reconnect-text {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
color: var(--error-color);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.meta-intro {
|
||||
margin: 0 0 16px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.meta-intro-link {
|
||||
/* --accent-ink, not --accent-solid: the latter is the lime fill and stays
|
||||
lime in both themes, which is unreadable as text on a light background. */
|
||||
color: var(--accent-ink);
|
||||
text-decoration: underline;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta-intro-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.meta-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.meta-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
background: var(--background-soft);
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meta-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.meta-btn {
|
||||
padding: 9px 16px;
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--background-soft);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.meta-btn-primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.meta-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.meta-signup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meta-signup-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.meta-link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.meta-link-btn:hover {
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<!-- The webhook address and verify token of a WhatsApp number or Messenger
|
||||
Page connected from the customer's own Meta app. -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ChannelAccount } from '@/services/channels'
|
||||
import CopyField from './CopyField.vue'
|
||||
|
||||
const props = defineProps<{ account: ChannelAccount }>()
|
||||
|
||||
const hint = computed(() => {
|
||||
if (props.account.channel_type === 'messenger') {
|
||||
return '在你的 Meta 应用中,将以下信息添加到 Messenger → 设置 → Webhooks,然后将公共主页订阅消息事件。'
|
||||
}
|
||||
return props.account.webhook_auto_configured
|
||||
? '该号码的消息会直接发送到 ReplyPier,无需额外配置。'
|
||||
: '在你的 Meta 应用中,将以下信息添加到 WhatsApp → 配置,以便消息送达 ReplyPier。'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="own-app-webhook">
|
||||
<p class="own-app-hint">{{ hint }}</p>
|
||||
<CopyField field-id="own-app-callback-url" label="回调 URL" name="回调 URL"
|
||||
:value="account.webhook_url || ''" />
|
||||
<CopyField field-id="own-app-verify-token" label="验证 Token" name="验证 Token"
|
||||
:value="account.webhook_verify_token || ''" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.own-app-webhook {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
}
|
||||
|
||||
.own-app-hint {
|
||||
margin: 0 0 12px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import channelsService, { type ChannelAccount } from '@/services/channels'
|
||||
import { agentService } from '@/services/agent'
|
||||
import type { Agent } from '@/types/agent'
|
||||
|
||||
const props = defineProps<{
|
||||
existingAccount?: ChannelAccount | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'connected', account: ChannelAccount): void
|
||||
}>()
|
||||
|
||||
const botToken = ref('')
|
||||
const connecting = ref(false)
|
||||
const account = ref<ChannelAccount | null>(props.existingAccount ?? null)
|
||||
|
||||
const agents = ref<Agent[]>([])
|
||||
const selectedAgentId = ref('')
|
||||
const savingAgent = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
agents.value = await agentService.getOrganizationAgents()
|
||||
selectedAgentId.value = String(
|
||||
props.existingAccount?.agent_id || agents.value[0]?.id || '')
|
||||
} catch (error) {
|
||||
console.error('Error loading agents:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const connect = async () => {
|
||||
if (!botToken.value.trim()) {
|
||||
toast.error('请粘贴来自 @BotFather 的机器人 Token')
|
||||
return
|
||||
}
|
||||
try {
|
||||
connecting.value = true
|
||||
account.value = await channelsService.connectTelegram(botToken.value.trim())
|
||||
toast.success(`已连接 ${account.value.display_name || 'Telegram 机器人'}`)
|
||||
} catch (error: any) {
|
||||
const detail = error?.response?.data?.detail || '连接 Telegram 机器人失败'
|
||||
toast.error(detail)
|
||||
} finally {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveAgent = async () => {
|
||||
if (!account.value || !selectedAgentId.value) return
|
||||
try {
|
||||
savingAgent.value = true
|
||||
const updated = await channelsService.setAccountAgent(account.value.id, selectedAgentId.value)
|
||||
toast.success('智能体已分配 — 你的机器人已生效!')
|
||||
emit('connected', updated)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.detail || '分配智能体失败')
|
||||
} finally {
|
||||
savingAgent.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tg-modal" @click.self="emit('close')">
|
||||
<div class="tg-modal-content">
|
||||
<div class="tg-modal-header">
|
||||
<h3>连接 Telegram</h3>
|
||||
<button class="tg-close-btn" @click="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: bot token -->
|
||||
<div v-if="!account" class="tg-modal-body">
|
||||
<ol class="tg-steps">
|
||||
<li>在 Telegram 中打开 <strong>@BotFather</strong>,发送 <code>/newbot</code></li>
|
||||
<li>按提示为你的机器人命名</li>
|
||||
<li>复制机器人 Token 并粘贴到下方</li>
|
||||
</ol>
|
||||
<label class="tg-label" for="tg-token">机器人 Token</label>
|
||||
<input
|
||||
id="tg-token"
|
||||
v-model="botToken"
|
||||
type="password"
|
||||
class="tg-input"
|
||||
placeholder="123456789:AAF…"
|
||||
name="tg-bot-token"
|
||||
autocomplete="new-password"
|
||||
@keyup.enter="connect"
|
||||
/>
|
||||
<div class="tg-actions">
|
||||
<button class="tg-btn tg-btn-secondary" @click="emit('close')">取消</button>
|
||||
<button class="tg-btn tg-btn-primary" :disabled="connecting" @click="connect">
|
||||
{{ connecting ? '连接中…' : '连接' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: route to an agent -->
|
||||
<div v-else class="tg-modal-body">
|
||||
<p class="tg-connected-note">
|
||||
<strong>{{ account.display_name }}</strong> 已连接。
|
||||
选择由哪个 AI 智能体应答其消息:
|
||||
</p>
|
||||
<label class="tg-label" for="tg-agent">AI 智能体</label>
|
||||
<select id="tg-agent" v-model="selectedAgentId" class="tg-input">
|
||||
<option v-for="agent in agents" :key="String(agent.id)" :value="String(agent.id)">
|
||||
{{ agent.display_name || agent.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="tg-actions">
|
||||
<button class="tg-btn tg-btn-secondary" @click="emit('connected', account)">暂时跳过</button>
|
||||
<button class="tg-btn tg-btn-primary" :disabled="savingAgent || !selectedAgentId" @click="saveAgent">
|
||||
{{ savingAgent ? '保存中…' : '分配智能体' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tg-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.tg-modal-content {
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
width: min(440px, calc(100vw - 32px));
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.tg-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tg-modal-header h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.tg-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tg-steps {
|
||||
margin: 0 0 16px;
|
||||
padding-left: 20px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tg-steps code {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--background-mute);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tg-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tg-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
background: var(--background-soft);
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tg-connected-note {
|
||||
margin: 0 0 16px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tg-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.tg-btn {
|
||||
padding: 9px 16px;
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--background-soft);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tg-btn-primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.tg-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,415 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import channelsService, {
|
||||
type ChannelAccount,
|
||||
type WhatsAppTemplate,
|
||||
} from '@/services/channels'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import { templatePreviewText, templateKey } from '@/utils/whatsappTemplates'
|
||||
import { languageLabel } from '@/utils/whatsappLanguages'
|
||||
|
||||
const props = defineProps<{
|
||||
accounts: ChannelAccount[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const accountId = ref(props.accounts[0]?.id ?? '')
|
||||
const templates = ref<WhatsAppTemplate[]>([])
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
|
||||
/**
|
||||
* Templates are written in Meta's Template Library, not here — it has ~150
|
||||
* pre-written, pre-localised ones built to pass its own review.
|
||||
*
|
||||
* The link is fetched rather than built: it is scoped to this number's Business
|
||||
* Account, and only the server can resolve which that is. It is fetched up
|
||||
* front so the anchor is a real href by the time it is clicked — resolving it
|
||||
* on click, after an await, would hand the browser a popup to block.
|
||||
*/
|
||||
const libraryUrl = ref('')
|
||||
|
||||
// Guards against a slow response for a previously selected number landing after
|
||||
// a newer one and rendering its templates under the wrong account.
|
||||
let loadToken = 0
|
||||
// The link's own token: it is keyed to the account, not the list, so the two
|
||||
// reload on different schedules and cannot invalidate each other.
|
||||
let libraryToken = 0
|
||||
|
||||
const load = async () => {
|
||||
const token = ++loadToken
|
||||
loadError.value = ''
|
||||
if (!accountId.value) {
|
||||
templates.value = []
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await channelsService.listWhatsAppTemplates(accountId.value)
|
||||
if (token !== loadToken) return
|
||||
templates.value = result
|
||||
} catch (error: any) {
|
||||
if (token !== loadToken) return
|
||||
loadError.value = error?.response?.data?.detail || '无法加载模板'
|
||||
templates.value = []
|
||||
} finally {
|
||||
if (token === loadToken) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetched alongside the list but deliberately not awaited with it: this costs a
|
||||
* Graph call, and failing it must not take the list down with it. Without a URL
|
||||
* the link simply doesn't render.
|
||||
*
|
||||
* Only ever called for a change of account. The link is derived from the
|
||||
* account's Business Account, so re-resolving it when the list reloads would
|
||||
* spend a Graph round trip to learn what we already know — and the list
|
||||
* reloads on every "refresh" click.
|
||||
*/
|
||||
const loadLibraryUrl = async () => {
|
||||
const token = ++libraryToken
|
||||
libraryUrl.value = ''
|
||||
if (!accountId.value) return
|
||||
try {
|
||||
const url = await channelsService.getWhatsAppTemplateLibraryUrl(accountId.value)
|
||||
if (token === libraryToken) libraryUrl.value = url
|
||||
} catch {
|
||||
// Left empty: the list is still useful, and there is nothing to act on.
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadLibraryUrl()
|
||||
})
|
||||
|
||||
watch(accountId, () => {
|
||||
load()
|
||||
loadLibraryUrl()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal title="WhatsApp 模板" width="620px" @close="emit('close')">
|
||||
<p class="wtm-intro">
|
||||
客户 24 小时会话窗口关闭后,可通过模板重新发起会话。模板需在 WhatsApp Manager 中编写;
|
||||
经 Meta 审核通过后会显示在此处,客服即可发送。
|
||||
</p>
|
||||
|
||||
<label v-if="accounts.length > 1" class="wtm-field">
|
||||
<span class="wtm-label">号码</span>
|
||||
<select v-model="accountId" class="wtm-input">
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">
|
||||
{{ account.display_name || account.external_account_id }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="wtm-body">
|
||||
<div v-if="loading" class="wtm-skeleton" aria-live="polite" aria-busy="true">
|
||||
<div v-for="n in 3" :key="n" class="wtm-skeleton-row"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="wtm-empty">
|
||||
<font-awesome-icon icon="fa-solid fa-circle-exclamation" />
|
||||
<p>{{ loadError }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="templates.length === 0" class="wtm-empty">
|
||||
<p>暂无模板。</p>
|
||||
</div>
|
||||
|
||||
<ul v-else class="wtm-list">
|
||||
<!-- Keyed on name+language: one name can have a row per language, and
|
||||
Vue needs them distinct. -->
|
||||
<li v-for="template in templates" :key="templateKey(template)" class="wtm-row">
|
||||
<div class="wtm-row-main">
|
||||
<div class="wtm-row-head">
|
||||
<span class="wtm-name">{{ template.name }}</span>
|
||||
<span v-if="template.status" class="wtm-pill" :class="template.status.toLowerCase()">
|
||||
{{ template.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="wtm-row-meta">
|
||||
<span v-if="template.category">{{ template.category }}</span>
|
||||
<span v-if="template.language">· {{ languageLabel(template.language) }}</span>
|
||||
</div>
|
||||
<p class="wtm-row-body">{{ templatePreviewText(template) }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Meta's Template Library is where these get written: ~150 pre-written,
|
||||
pre-localised templates, already shaped to pass its review. -->
|
||||
<div v-if="accountId && !loading" class="wtm-create-guide">
|
||||
<h4 class="wtm-guide-title">添加模板</h4>
|
||||
<ol class="wtm-steps">
|
||||
<li>打开 WhatsApp Manager,进入<strong>模板库</strong>。</li>
|
||||
<li>选择现成模板,或选择<strong>创建模板</strong>自行编写。</li>
|
||||
<li>
|
||||
待 Meta 审核通过后,返回此处并点击<button type="button" class="wtm-link" @click="load">刷新</button>。
|
||||
</li>
|
||||
</ol>
|
||||
<a
|
||||
v-if="libraryUrl"
|
||||
class="wtm-btn wtm-btn-primary wtm-guide-link"
|
||||
:href="libraryUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
打开 WhatsApp Manager
|
||||
<font-awesome-icon icon="fa-solid fa-arrow-up-right-from-square" />
|
||||
</a>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.wtm-intro {
|
||||
margin: 0 0 16px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.wtm-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wtm-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wtm-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
background: var(--background-soft);
|
||||
}
|
||||
|
||||
.wtm-row-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wtm-row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wtm-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Neutral by default: Meta adds statuses over time (PENDING_DELETION, IN_APPEAL,
|
||||
FLAGGED, ...) and an unrecognised one must still read as a pill, not vanish. */
|
||||
.wtm-pill {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--c-neutral) 16%, transparent);
|
||||
color: var(--c-neutral);
|
||||
border: 1px solid color-mix(in srgb, var(--c-neutral) 35%, transparent);
|
||||
}
|
||||
|
||||
.wtm-pill.approved {
|
||||
background: color-mix(in srgb, var(--c-positive) 16%, transparent);
|
||||
color: var(--c-positive);
|
||||
border-color: color-mix(in srgb, var(--c-positive) 35%, transparent);
|
||||
}
|
||||
|
||||
.wtm-pill.pending {
|
||||
background: color-mix(in srgb, var(--c-warn) 16%, transparent);
|
||||
color: var(--c-warn);
|
||||
border-color: color-mix(in srgb, var(--c-warn) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Rejected, paused and disabled all mean "cannot send" */
|
||||
.wtm-pill.rejected,
|
||||
.wtm-pill.paused,
|
||||
.wtm-pill.disabled {
|
||||
background: color-mix(in srgb, var(--c-danger) 16%, transparent);
|
||||
color: var(--c-danger);
|
||||
border-color: color-mix(in srgb, var(--c-danger) 35%, transparent);
|
||||
}
|
||||
|
||||
.wtm-row-meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.wtm-row-body {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.wtm-field {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.wtm-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wtm-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
background: var(--background-soft);
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.wtm-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Mirrors .wtm-row, the row it stands in for: a real row is legible because of
|
||||
its border, not its fill (--background-soft is #0E0F14 against a #0B0C10
|
||||
modal — a contrast ratio of 1.02). Without the border this was rendering
|
||||
perfectly and showing nothing, so the modal just looked blank until the
|
||||
fetch returned. */
|
||||
.wtm-skeleton-row {
|
||||
height: 64px;
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--o08);
|
||||
animation: wtm-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Only down to 0.6: at 0.5 the trough fell back below the threshold where the
|
||||
row reads as being there at all. */
|
||||
@keyframes wtm-pulse {
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wtm-skeleton-row {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wtm-empty {
|
||||
text-align: center;
|
||||
padding: 24px 8px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.wtm-empty p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.wtm-create-guide {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.wtm-guide-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wtm-steps {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 20px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* A button, because it acts on this page — styled as the inline link it reads as */
|
||||
.wtm-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--c-info);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wtm-guide-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wtm-btn {
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--background-soft);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.wtm-btn-primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,438 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useJiraTicket } from '@/composables/useJiraTicket'
|
||||
|
||||
const props = defineProps<{
|
||||
chatId: string
|
||||
initialSummary?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'ticketCreated', ticketKey: string): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
jiraConnected,
|
||||
jiraLoading,
|
||||
showTicketModal,
|
||||
jiraProjects,
|
||||
jiraIssueTypes,
|
||||
jiraPriorities,
|
||||
selectedProject,
|
||||
selectedIssueType,
|
||||
selectedPriority,
|
||||
ticketSummary,
|
||||
ticketDescription,
|
||||
loadingProjects,
|
||||
loadingIssueTypes,
|
||||
loadingPriorities,
|
||||
creatingTicket,
|
||||
createdTicketKey,
|
||||
priorityAvailable,
|
||||
checkingPriorityAvailability,
|
||||
isFormValid,
|
||||
checkJiraStatus,
|
||||
handleProjectChange,
|
||||
handleIssueTypeChange,
|
||||
openTicketModal,
|
||||
closeTicketModal,
|
||||
submitTicket
|
||||
} = useJiraTicket()
|
||||
|
||||
// Initialize the modal
|
||||
const initModal = async () => {
|
||||
await openTicketModal(props.initialSummary)
|
||||
}
|
||||
|
||||
// Handle close
|
||||
const handleClose = () => {
|
||||
closeTicketModal()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = async () => {
|
||||
const ticketKey = await submitTicket(props.chatId)
|
||||
if (ticketKey) {
|
||||
emit('ticketCreated', ticketKey)
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on mount
|
||||
initModal()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ticket-modal">
|
||||
<div class="ticket-modal-content">
|
||||
<div class="ticket-modal-header">
|
||||
<h3>创建 Jira 问题</h3>
|
||||
<button class="close-modal-btn" @click="handleClose">
|
||||
<font-awesome-icon icon="fa-solid fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="jiraLoading" class="ticket-loading">
|
||||
<font-awesome-icon icon="fa-solid fa-spinner" spin />
|
||||
正在检查 Jira 连接…
|
||||
</div>
|
||||
|
||||
<div v-else-if="!jiraConnected" class="ticket-not-connected">
|
||||
<font-awesome-icon icon="fa-solid fa-exclamation-triangle" />
|
||||
<p>Jira 尚未连接,请在设置中连接 Jira。</p>
|
||||
<router-link to="/settings/integrations" class="connect-jira-btn">
|
||||
连接 Jira
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else class="ticket-form">
|
||||
<div class="form-group">
|
||||
<label for="ticket-project">项目 *</label>
|
||||
<div v-if="loadingProjects" class="loading-indicator">
|
||||
<font-awesome-icon icon="fa-solid fa-spinner" spin /> 加载项目中…
|
||||
</div>
|
||||
<select
|
||||
v-else
|
||||
id="ticket-project"
|
||||
v-model="selectedProject"
|
||||
@change="handleProjectChange(selectedProject)"
|
||||
>
|
||||
<option value="">选择项目</option>
|
||||
<option
|
||||
v-for="project in jiraProjects"
|
||||
:key="project.id"
|
||||
:value="project.key"
|
||||
>
|
||||
{{ project.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ticket-issue-type">问题类型 *</label>
|
||||
<div v-if="loadingIssueTypes" class="loading-indicator">
|
||||
<font-awesome-icon icon="fa-solid fa-spinner" spin /> 加载问题类型中…
|
||||
</div>
|
||||
<select
|
||||
v-else
|
||||
id="ticket-issue-type"
|
||||
v-model="selectedIssueType"
|
||||
:disabled="!selectedProject"
|
||||
@change="handleIssueTypeChange(selectedIssueType)"
|
||||
>
|
||||
<option value="">选择问题类型</option>
|
||||
<option
|
||||
v-for="issueType in jiraIssueTypes"
|
||||
:key="issueType.id"
|
||||
:value="issueType.id"
|
||||
>
|
||||
{{ issueType.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedProject && selectedIssueType" class="form-group">
|
||||
<div v-if="checkingPriorityAvailability" class="loading-indicator">
|
||||
<font-awesome-icon icon="fa-solid fa-spinner" spin /> 正在检查优先级字段…
|
||||
</div>
|
||||
|
||||
<template v-else-if="priorityAvailable">
|
||||
<label for="ticket-priority">优先级</label>
|
||||
<div v-if="loadingPriorities" class="loading-indicator">
|
||||
<font-awesome-icon icon="fa-solid fa-spinner" spin /> 加载优先级中…
|
||||
</div>
|
||||
<select
|
||||
v-else
|
||||
id="ticket-priority"
|
||||
v-model="selectedPriority"
|
||||
>
|
||||
<option value="">选择优先级</option>
|
||||
<option
|
||||
v-for="priority in jiraPriorities"
|
||||
:key="priority.id"
|
||||
:value="priority.id"
|
||||
>
|
||||
{{ priority.name }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<div v-else class="priority-unavailable">
|
||||
<font-awesome-icon icon="fa-solid fa-info-circle" />
|
||||
此项目 / 问题类型不支持优先级字段
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ticket-summary">主题 *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ticket-summary"
|
||||
v-model="ticketSummary"
|
||||
placeholder="输入问题主题"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ticket-description">描述</label>
|
||||
<textarea
|
||||
id="ticket-description"
|
||||
v-model="ticketDescription"
|
||||
placeholder="输入问题描述"
|
||||
rows="4"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="ticket-actions">
|
||||
<button class="cancel-btn" @click="handleClose">取消</button>
|
||||
<button
|
||||
class="create-btn"
|
||||
@click="handleSubmit"
|
||||
:disabled="!isFormValid || creatingTicket"
|
||||
>
|
||||
<font-awesome-icon v-if="creatingTicket" icon="fa-solid fa-spinner" spin />
|
||||
{{ creatingTicket ? '创建中…' : '创建问题' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="createdTicketKey" class="ticket-created">
|
||||
<font-awesome-icon icon="fa-solid fa-check-circle" />
|
||||
问题已创建:<strong>{{ createdTicketKey }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ticket-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.ticket-modal-content {
|
||||
background: var(--background-color);
|
||||
border-radius: 8px;
|
||||
width: 500px;
|
||||
max-width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.ticket-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ticket-modal-header h3 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.close-modal-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.close-modal-btn:hover {
|
||||
background: var(--background-mute);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ticket-loading,
|
||||
.ticket-not-connected {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ticket-loading svg,
|
||||
.ticket-not-connected svg {
|
||||
font-size: 24px;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ticket-not-connected svg {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.connect-jira-btn {
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
padding: 8px 16px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.connect-jira-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ticket-form {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-group select:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
padding: 10px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-indicator svg {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.ticket-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: var(--background-mute);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.create-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.ticket-created {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: var(--success-light);
|
||||
color: var(--success);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ticket-created svg {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.priority-unavailable {
|
||||
padding: 10px;
|
||||
background-color: var(--background-mute);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.priority-unavailable svg {
|
||||
color: var(--warning);
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts" name="EnablePushPrompt">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { isShopifyEmbedded } from '@/pwa/register'
|
||||
import { usePWAInstall } from '@/composables/usePWAInstall'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
const emit = defineEmits<{
|
||||
// Parent (DashboardLayout) owns the notifications composable instance and
|
||||
// performs the actual permission request from this user gesture.
|
||||
(e: 'enable'): void
|
||||
}>()
|
||||
|
||||
const SNOOZE_KEY = 'rp-push-prompt-snoozed-at'
|
||||
const SNOOZE_MS = 14 * 24 * 60 * 60 * 1000
|
||||
|
||||
const visible = ref(false)
|
||||
const needsInstallFirst = ref(false)
|
||||
const { isIOS, isStandalone } = usePWAInstall()
|
||||
|
||||
const isSnoozed = () => {
|
||||
const at = Number(localStorage.getItem(SNOOZE_KEY) || 0)
|
||||
return at > 0 && Date.now() - at < SNOOZE_MS
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isShopifyEmbedded() || isSnoozed()) return
|
||||
|
||||
if (isIOS && !isStandalone.value) {
|
||||
// Safari tab on iOS: the Push API only exists once installed to Home Screen
|
||||
needsInstallFirst.value = true
|
||||
visible.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
visible.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const snooze = () => {
|
||||
localStorage.setItem(SNOOZE_KEY, String(Date.now()))
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const enable = () => {
|
||||
emit('enable')
|
||||
// The browser permission dialog takes over from here
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="push-prompt">
|
||||
<div v-if="visible" class="push-prompt" role="dialog" aria-label="开启通知">
|
||||
<div class="bell-tile" aria-hidden="true" v-html="navIconSvg('bell', 28)"></div>
|
||||
|
||||
<template v-if="needsInstallFirst">
|
||||
<div class="prompt-title">安装后接收通知</div>
|
||||
<div class="prompt-body">
|
||||
请先把 ReplyPier 添加到主屏幕 — 在 iPhone 上,推送通知仅在已安装的应用中生效。
|
||||
点按分享图标,然后选择“添加到主屏幕”。
|
||||
</div>
|
||||
<button type="button" class="secondary-btn" @click="snooze">知道了</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="prompt-title">客户需要你时及时获知</div>
|
||||
<div class="prompt-body">
|
||||
AI 客服转接会话时,我们会立即通知你 — 不让客户久等。
|
||||
</div>
|
||||
<button type="button" class="primary-btn" @click="enable">
|
||||
开启通知
|
||||
</button>
|
||||
<button type="button" class="secondary-btn" @click="snooze">暂不</button>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.push-prompt {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: var(--z-prompt);
|
||||
width: 340px;
|
||||
max-width: calc(100vw - 40px);
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bell-tile {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 18px;
|
||||
background: var(--teal-bg);
|
||||
border: 1px solid var(--teal-border);
|
||||
color: var(--c-teal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.prompt-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 19px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: var(--tracking-display);
|
||||
line-height: 1.2;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.prompt-body {
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
margin-top: var(--space-lg);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 15px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
cursor: pointer;
|
||||
transition: filter var(--transition-fast);
|
||||
}
|
||||
|
||||
.primary-btn:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
margin-top: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Mobile: bottom sheet over the nav, safe-area aware */
|
||||
@media (max-width: 768px) {
|
||||
.push-prompt {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 24px 24px 0 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
padding-bottom: calc(var(--space-lg) + var(--safe-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
.push-prompt-enter-active,
|
||||
.push-prompt-leave-active {
|
||||
transition: opacity var(--transition-normal), transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.push-prompt-enter-from,
|
||||
.push-prompt-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,580 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { notificationService, type Notification } from '@/services/notification'
|
||||
import { getNotificationIcon } from './notificationIcons'
|
||||
import { conversationSessionUrl } from '@/pwa/pushContract'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [],
|
||||
'notification-read': []
|
||||
}>()
|
||||
|
||||
const notifications = ref<Notification[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
notifications.value = await notificationService.getNotifications()
|
||||
} catch (err) {
|
||||
error.value = '通知加载失败'
|
||||
console.error('Error fetching notifications:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const markAsRead = async (id: number) => {
|
||||
try {
|
||||
await notificationService.markAsRead(id)
|
||||
const notification = notifications.value.find(n => n.id === id)
|
||||
if (notification) {
|
||||
notification.is_read = true
|
||||
emit('notification-read')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error marking notification as read:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Chat notifications (new chat, transfer, assignment) carry the session id in
|
||||
// their metadata; clicking one marks it read and deep-links to that
|
||||
// conversation, closing the drawer.
|
||||
const sessionIdOf = (notification: Notification): string | undefined => {
|
||||
const id = notification.notification_metadata?.session_id
|
||||
return id ? String(id) : undefined
|
||||
}
|
||||
|
||||
const handleNotificationClick = async (notification: Notification) => {
|
||||
if (!notification.is_read) {
|
||||
await markAsRead(notification.id)
|
||||
}
|
||||
const sessionId = sessionIdOf(notification)
|
||||
if (sessionId) {
|
||||
emit('close')
|
||||
router.push(conversationSessionUrl(sessionId))
|
||||
}
|
||||
}
|
||||
|
||||
const activeFilter = ref<'all' | 'unread'>('all')
|
||||
|
||||
const filteredNotifications = computed(() =>
|
||||
activeFilter.value === 'unread'
|
||||
? notifications.value.filter(n => !n.is_read)
|
||||
: notifications.value
|
||||
)
|
||||
|
||||
const hasUnread = computed(() => notifications.value.some(n => !n.is_read))
|
||||
|
||||
// One request that clears everything, not one per loaded row. The list is a
|
||||
// 50-row page, so looping it could never clear a badge showing 99+ however many
|
||||
// times you tapped.
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await notificationService.markAllAsRead()
|
||||
notifications.value.forEach(n => { n.is_read = true })
|
||||
emit('notification-read')
|
||||
} catch (err) {
|
||||
console.error('Error marking all notifications as read:', err)
|
||||
error.value = '无法全部标为已读'
|
||||
}
|
||||
}
|
||||
|
||||
const deleteNotification = async (id: number) => {
|
||||
const previous = notifications.value
|
||||
// Optimistic: the row disappears on tap, and comes back if the call fails.
|
||||
notifications.value = notifications.value.filter(n => n.id !== id)
|
||||
try {
|
||||
await notificationService.deleteNotification(id)
|
||||
emit('notification-read')
|
||||
} catch (err) {
|
||||
console.error('Error deleting notification:', err)
|
||||
notifications.value = previous
|
||||
error.value = '无法删除该通知'
|
||||
}
|
||||
}
|
||||
|
||||
const clearAll = async () => {
|
||||
const previous = notifications.value
|
||||
notifications.value = []
|
||||
try {
|
||||
await notificationService.clearAll()
|
||||
emit('notification-read')
|
||||
} catch (err) {
|
||||
console.error('Error clearing notifications:', err)
|
||||
notifications.value = previous
|
||||
error.value = '无法清空通知'
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: string): string => {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
// Less than a minute
|
||||
if (diff < 60000) {
|
||||
return '刚刚'
|
||||
}
|
||||
|
||||
// Less than an hour
|
||||
if (diff < 3600000) {
|
||||
const mins = Math.floor(diff / 60000)
|
||||
return `${mins} 分钟前`
|
||||
}
|
||||
|
||||
// Less than a day
|
||||
if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
return `${hours} 小时前`
|
||||
}
|
||||
|
||||
// Show date
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
onMounted(fetchNotifications)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notification-drawer" :class="{ open: isOpen }">
|
||||
<div class="drawer-header">
|
||||
<h3>通知</h3>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="refresh-button"
|
||||
@click="fetchNotifications"
|
||||
:disabled="isLoading"
|
||||
:class="{ 'loading': isLoading }"
|
||||
title="刷新"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="close-button" @click="emit('close')" title="关闭">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-filter">
|
||||
<div class="filter-tabs">
|
||||
<button class="filter-tab" :class="{ active: activeFilter === 'all' }" @click="activeFilter = 'all'">全部</button>
|
||||
<button class="filter-tab" :class="{ active: activeFilter === 'unread' }" @click="activeFilter = 'unread'">未读</button>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button class="mark-all" :disabled="!hasUnread" @click="markAllRead">全部已读</button>
|
||||
<button class="mark-all" :disabled="!notifications.length" @click="clearAll">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-content">
|
||||
<div v-if="isLoading" class="state-message">
|
||||
正在加载通知…
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="state-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="!filteredNotifications.length" class="empty-state">
|
||||
<div class="empty-title">全部看完了</div>
|
||||
<div class="empty-sub">暂无{{ activeFilter === 'unread' ? '未读' : '' }}通知。</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="notifications">
|
||||
<div v-for="notification in filteredNotifications" :key="notification.id" class="notification-item"
|
||||
:class="{ unread: !notification.is_read, linkable: sessionIdOf(notification) }"
|
||||
@click="handleNotificationClick(notification)">
|
||||
<span class="notification-icon-wrap">
|
||||
<img v-if="getNotificationIcon(notification.type.toLowerCase())"
|
||||
:src="getNotificationIcon(notification.type.toLowerCase())" class="notification-type-icon"
|
||||
:alt="notification.type" />
|
||||
</span>
|
||||
<div class="notification-body">
|
||||
<div class="notification-top">
|
||||
<span class="notification-title">{{ notification.title }}</span>
|
||||
<span class="notification-time">{{ formatTime(notification.created_at) }}</span>
|
||||
</div>
|
||||
<div class="notification-message">{{ notification.message }}</div>
|
||||
</div>
|
||||
<span class="unread-dot" :class="{ on: !notification.is_read }"></span>
|
||||
<!-- .stop: the row itself deep-links to the conversation -->
|
||||
<button
|
||||
class="delete-notification"
|
||||
:aria-label="`删除通知:${notification.title}`"
|
||||
title="删除"
|
||||
@click.stop="deleteNotification(notification.id)"
|
||||
>×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -380px;
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
height: 100vh;
|
||||
background: var(--bg2);
|
||||
border-left: 1px solid var(--o08);
|
||||
transition: right 0.3s ease;
|
||||
z-index: var(--z-drawer);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notification-drawer.open {
|
||||
right: 0;
|
||||
/* Only while open. Parked at right:-380px the panel is off-screen, but a
|
||||
shadow cast 20px leftward with a 50px blur still reaches ~45px back onto
|
||||
the page, above the content — and this drawer is mounted by
|
||||
DashboardLayout on every screen, so the band followed you everywhere.
|
||||
Invisible on the dark theme, a grey edge on the light one. */
|
||||
box-shadow: -20px 0 50px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--bg2);
|
||||
}
|
||||
|
||||
.drawer-header h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
color: var(--muted);
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.refresh-button:hover {
|
||||
background: var(--o06);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.refresh-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.refresh-button.loading svg {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--o12);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: var(--o06);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Filter row */
|
||||
.drawer-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 5px 13px;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Sits over the row; the row itself is the deep-link, so the button stops
|
||||
propagation. 32px keeps it thumb-reachable without crowding the text. */
|
||||
.delete-notification {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.delete-notification:hover,
|
||||
.delete-notification:focus-visible {
|
||||
opacity: 1;
|
||||
background: var(--o07);
|
||||
}
|
||||
|
||||
.mark-all {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--accent-ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
transition: filter 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.mark-all:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.mark-all:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--o05);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: var(--o03);
|
||||
}
|
||||
|
||||
/* Chat notifications deep-link to their conversation — hint it on hover. */
|
||||
.notification-item.linkable {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-item.linkable::after {
|
||||
content: '打开 ›';
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
bottom: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--accent-ink);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.notification-item.linkable:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background: var(--accent-bg-06);
|
||||
}
|
||||
|
||||
.notification-icon-wrap {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.notification-type-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
filter: var(--icon-filter, brightness(0) invert(1));
|
||||
opacity: var(--icon-opacity, 0.55);
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text2);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
margin-top: 3px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.unread-dot {
|
||||
flex-shrink: 0;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.unread-dot.on {
|
||||
background: var(--accent-solid);
|
||||
}
|
||||
|
||||
.state-message {
|
||||
padding: var(--space-md);
|
||||
text-align: center;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 14px;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 12.5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* Mobile: full-screen panel per design */
|
||||
@media (max-width: 768px) {
|
||||
.notification-drawer {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
right: -100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.notification-drawer.open {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
padding-top: calc(20px + var(--safe-top));
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
padding-bottom: var(--safe-bottom);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import chatIcon from '@/assets/chat.svg'
|
||||
import knowledgeIcon from '@/assets/knowledge.svg'
|
||||
|
||||
export const getNotificationIcon = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'knowledge_processed':
|
||||
case 'knowledge_failed':
|
||||
return knowledgeIcon
|
||||
case 'chat':
|
||||
return chatIcon
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { InvestigationEvent } from '@/types/ticket'
|
||||
|
||||
const props = defineProps<{ event: InvestigationEvent }>()
|
||||
|
||||
const isExpanded = ref(false)
|
||||
|
||||
function copyQuery() {
|
||||
if (!props.event.tool_input) return
|
||||
navigator.clipboard
|
||||
.writeText(props.event.tool_input)
|
||||
.then(() => toast.success('查询已复制'))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function formatTime(iso?: string | null): string {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="evidence-row" :class="{ failed: !!event.error }">
|
||||
<button class="evidence-head" @click="isExpanded = !isExpanded">
|
||||
<span class="tool-badge">{{ event.connector_name || 'MCP' }}</span>
|
||||
<span class="tool-name">{{ event.tool_name }}</span>
|
||||
<span v-if="event.error" class="error-tag">错误</span>
|
||||
<span class="evidence-meta">
|
||||
<span v-if="event.duration_ms != null" class="mono">{{ event.duration_ms }}ms</span>
|
||||
<span class="mono">{{ formatTime(event.created_at) }}</span>
|
||||
<span class="chevron">{{ isExpanded ? '▾' : '▸' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="isExpanded" class="evidence-body">
|
||||
<template v-if="event.tool_input">
|
||||
<div class="block-label-row">
|
||||
<span class="block-label">查询</span>
|
||||
<button class="copy-btn" @click="copyQuery">复制</button>
|
||||
</div>
|
||||
<pre class="code-block">{{ event.tool_input }}</pre>
|
||||
</template>
|
||||
<template v-if="event.error">
|
||||
<div class="block-label">错误</div>
|
||||
<pre class="code-block error">{{ event.error }}</pre>
|
||||
</template>
|
||||
<template v-else-if="event.tool_result">
|
||||
<div class="block-label">结果</div>
|
||||
<pre class="code-block">{{ event.tool_result }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.evidence-row {
|
||||
border: 1px solid var(--o07);
|
||||
border-radius: 10px;
|
||||
background: var(--bg2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.evidence-row.failed {
|
||||
border-color: color-mix(in srgb, var(--c-danger) 35%, transparent);
|
||||
}
|
||||
.evidence-head {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 8px 11px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.tool-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--c-teal);
|
||||
background: var(--teal-bg-10);
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tool-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--text3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.error-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
color: var(--c-danger);
|
||||
background: color-mix(in srgb, var(--c-danger) 12%, transparent);
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.evidence-meta {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: var(--faint);
|
||||
font-size: 10.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.chevron {
|
||||
color: var(--muted2);
|
||||
}
|
||||
.evidence-body {
|
||||
padding: 0 11px 11px;
|
||||
}
|
||||
.block-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.block-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
margin: 8px 0 5px;
|
||||
}
|
||||
.copy-btn {
|
||||
font-size: 10.5px;
|
||||
color: var(--accent-ink);
|
||||
background: var(--accent-bg-08);
|
||||
border: none;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.code-block {
|
||||
margin: 0;
|
||||
padding: 9px 11px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o07);
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: var(--text3);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.code-block.error {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { InvestigationEvent, InvestigationHypothesis } from '@/types/ticket'
|
||||
import { hypothesisMeta } from './ticketMeta'
|
||||
import EvidenceCitationRow from './EvidenceCitationRow.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
hypothesis: InvestigationHypothesis
|
||||
events: InvestigationEvent[]
|
||||
}>()
|
||||
|
||||
const isExpanded = ref(false)
|
||||
const meta = computed(() => hypothesisMeta(props.hypothesis.status))
|
||||
const isTesting = computed(() => props.hypothesis.status === 'testing')
|
||||
const evidence = computed(() =>
|
||||
props.events.filter((e) => e.hypothesis_id === props.hypothesis.id && e.event_type === 'tool_call'),
|
||||
)
|
||||
const confidencePct = computed(() =>
|
||||
props.hypothesis.confidence != null ? Math.round(props.hypothesis.confidence * 100) : null,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hypothesis-card" :id="`hypothesis-${hypothesis.idx}`">
|
||||
<button class="hypo-head" @click="isExpanded = !isExpanded">
|
||||
<span class="hypo-id mono">H{{ hypothesis.idx }}</span>
|
||||
<span class="hypo-title">{{ hypothesis.title }}</span>
|
||||
<span class="hypo-chip" :style="{ color: meta.color, borderColor: meta.color }">
|
||||
<span v-if="isTesting" class="pulse-dot" :style="{ background: meta.color }"></span>
|
||||
{{ meta.label }}
|
||||
</span>
|
||||
<span class="chevron">{{ isExpanded ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="confidencePct != null" class="confidence-row">
|
||||
<div class="confidence-track">
|
||||
<div
|
||||
class="confidence-fill"
|
||||
:style="{ width: `${confidencePct}%`, background: meta.color }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="confidence-value mono" :style="{ color: meta.color }">{{ (hypothesis.confidence ?? 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isExpanded" class="hypo-body">
|
||||
<p v-if="hypothesis.rationale" class="hypo-text">
|
||||
<span class="text-label">理由 · </span>{{ hypothesis.rationale }}
|
||||
</p>
|
||||
<p v-if="hypothesis.conclusion" class="hypo-text">
|
||||
<span class="text-label">结论 · </span>{{ hypothesis.conclusion }}
|
||||
</p>
|
||||
<template v-if="evidence.length">
|
||||
<div class="evidence-label">证据——{{ evidence.length }} 次工具调用</div>
|
||||
<div class="evidence-list">
|
||||
<EvidenceCitationRow v-for="event in evidence" :key="event.id" :event="event" />
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="no-evidence">无工具证据——基于工单上下文的推理验证。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hypothesis-card {
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
padding: 11px 13px;
|
||||
}
|
||||
.hypo-head {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
.hypo-id {
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--muted2);
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o08);
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.hypo-title {
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.hypo-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.04em;
|
||||
border: 1px solid;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pulse-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
animation: hypo-pulse 1.3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes hypo-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.chevron {
|
||||
color: var(--muted2);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.confidence-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.confidence-track {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--o07);
|
||||
overflow: hidden;
|
||||
}
|
||||
.confidence-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.confidence-value {
|
||||
font-size: 10.5px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
.hypo-body {
|
||||
margin-top: 11px;
|
||||
border-top: 1px solid var(--o06);
|
||||
padding-top: 11px;
|
||||
}
|
||||
.hypo-text {
|
||||
margin: 0 0 9px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text3);
|
||||
}
|
||||
.text-label {
|
||||
color: var(--faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
.evidence-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
margin: 11px 0 7px;
|
||||
}
|
||||
.evidence-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.no-evidence {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A note that steers the next investigation. Used by the L2 approval banner
|
||||
* when rejecting a proposal, and by the investigation panel for a plain
|
||||
* guided re-run — the two differ only in wording and button colour, so the
|
||||
* autofocus, the keyboard shortcuts and the trim live here rather than twice.
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
placeholder: string
|
||||
submitLabel: string
|
||||
tone?: 'danger' | 'accent'
|
||||
busy?: boolean
|
||||
}>(),
|
||||
{ tone: 'accent', busy: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'submit', note: string): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const note = ref('')
|
||||
const input = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
onMounted(() => input.value?.focus())
|
||||
|
||||
// An empty note is allowed: re-running unchanged is a legitimate thing to
|
||||
// want, and the reject flow has always permitted a reason-less rejection.
|
||||
const submit = () => emit('submit', note.value.trim())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="note-form">
|
||||
<textarea
|
||||
ref="input"
|
||||
v-model="note"
|
||||
class="note-input"
|
||||
:placeholder="placeholder"
|
||||
@keydown.esc.stop="emit('cancel')"
|
||||
@keydown.enter.meta.stop.prevent="submit"
|
||||
@keydown.enter.ctrl.stop.prevent="submit"
|
||||
></textarea>
|
||||
<slot />
|
||||
<div class="note-actions">
|
||||
<button type="button" class="cancel-btn" @click="emit('cancel')">取消</button>
|
||||
<button
|
||||
type="button"
|
||||
class="submit-btn"
|
||||
:class="tone"
|
||||
:disabled="busy"
|
||||
@click="submit"
|
||||
>
|
||||
{{ submitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.note-form {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 11px;
|
||||
padding: 12px;
|
||||
}
|
||||
.note-input {
|
||||
width: 100%;
|
||||
min-height: 68px;
|
||||
resize: vertical;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 9px;
|
||||
padding: 9px 11px;
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
outline: none;
|
||||
}
|
||||
.note-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 9px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.cancel-btn {
|
||||
padding: 7px 13px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.submit-btn {
|
||||
padding: 7px 15px;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.submit-btn.danger {
|
||||
background: var(--c-danger);
|
||||
color: var(--on-light, #fff);
|
||||
}
|
||||
.submit-btn.accent {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ticketService } from '@/services/tickets'
|
||||
import { useTicketSocket } from '@/composables/useTicketSocket'
|
||||
import TicketStatusBadge from './TicketStatusBadge.vue'
|
||||
import TicketAiStateChip from './TicketAiStateChip.vue'
|
||||
import type { Ticket } from '@/types/ticket'
|
||||
|
||||
const props = defineProps<{ sessionId: string }>()
|
||||
const router = useRouter()
|
||||
const ticket = ref<Ticket | null>(null)
|
||||
|
||||
async function load() {
|
||||
ticket.value = props.sessionId
|
||||
? await ticketService.getTicketBySession(props.sessionId)
|
||||
: null
|
||||
}
|
||||
|
||||
useTicketSocket((event) => {
|
||||
if (ticket.value && event.ticket_id === ticket.value.id) load()
|
||||
})
|
||||
|
||||
// Gentle refresh keeps the card honest if the ticket was just created by the
|
||||
// chat AI mid-conversation.
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
onMounted(() => {
|
||||
load()
|
||||
timer = setInterval(load, 20000)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
watch(() => props.sessionId, load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="ticket" class="linked-ticket-card">
|
||||
<div class="card-header">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--accent-ink)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2 2 2 0 0 0 0 4 2 2 0 0 1-2 2H6a2 2 0 0 1-2-2 2 2 0 0 0 0-4z"></path>
|
||||
</svg>
|
||||
<span class="card-title">关联工单</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chip-row">
|
||||
<span class="ticket-number">{{ ticket.display_number }}</span>
|
||||
<TicketStatusBadge :status="ticket.status" />
|
||||
</div>
|
||||
<div class="ticket-title">{{ ticket.title }}</div>
|
||||
<TicketAiStateChip :state="ticket.ai_state" class="ai-line" />
|
||||
<button class="view-btn" @click="router.push(`/tickets/${ticket.id}`)">
|
||||
查看工单 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.linked-ticket-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 11px 15px;
|
||||
background: var(--accent-bg-08);
|
||||
border-bottom: 1px solid var(--o07);
|
||||
}
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: 12.5px;
|
||||
color: var(--accent-ink);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.card-body {
|
||||
padding: 15px;
|
||||
}
|
||||
.chip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
.ticket-number {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
.ticket-title {
|
||||
font-size: 13.5px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 11px;
|
||||
color: var(--text);
|
||||
}
|
||||
.ai-line {
|
||||
margin-bottom: 14px;
|
||||
display: inline-flex;
|
||||
}
|
||||
.view-btn {
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,274 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { zhCN } from 'date-fns/locale'
|
||||
import type { TicketActivity } from '@/types/ticket'
|
||||
import { ticketInitials } from './ticketMeta'
|
||||
|
||||
defineProps<{
|
||||
activities: TicketActivity[]
|
||||
canComment: boolean
|
||||
isSaving: boolean
|
||||
/** Delivery path to the customer exists — shows the "Send to customer" toggle. */
|
||||
canMessageCustomer?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'comment', body: string, isInternal: boolean): void }>()
|
||||
|
||||
const draft = ref('')
|
||||
const customerVisible = ref(false)
|
||||
|
||||
function submit() {
|
||||
if (!draft.value.trim()) return
|
||||
emit('comment', draft.value, !customerVisible.value)
|
||||
draft.value = ''
|
||||
customerVisible.value = false
|
||||
}
|
||||
|
||||
const timeAgo = (iso?: string | null) =>
|
||||
iso ? formatDistanceToNow(new Date(iso), { addSuffix: true, locale: zhCN }) : ''
|
||||
|
||||
function actorName(activity: TicketActivity): string {
|
||||
if (activity.actor_name) return activity.actor_name
|
||||
if (activity.actor_type === 'ai') return 'ReplyPier AI'
|
||||
if (activity.actor_type === 'customer') return '客户'
|
||||
return '系统'
|
||||
}
|
||||
|
||||
function avatarText(activity: TicketActivity): string {
|
||||
if (activity.actor_type === 'ai') return 'AI'
|
||||
if (activity.actor_type === 'system') return '◇'
|
||||
return ticketInitials(actorName(activity))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="activity-feed">
|
||||
<div class="feed-header">动态</div>
|
||||
<div class="feed-body">
|
||||
<div v-if="!activities.length" class="feed-empty">暂无动态。</div>
|
||||
<div v-for="activity in activities" :key="activity.id" class="feed-item">
|
||||
<span
|
||||
class="avatar"
|
||||
:class="{ ai: activity.actor_type === 'ai', system: activity.actor_type === 'system' }"
|
||||
>
|
||||
{{ avatarText(activity) }}
|
||||
</span>
|
||||
<div class="item-main">
|
||||
<div class="item-header">
|
||||
<span class="author">{{ actorName(activity) }}</span>
|
||||
<span v-if="activity.actor_type === 'ai'" class="ai-tag">AI</span>
|
||||
<span
|
||||
v-if="activity.activity_type === 'comment' && !activity.is_internal"
|
||||
class="visible-tag"
|
||||
>
|
||||
已发送给客户
|
||||
</span>
|
||||
<span
|
||||
v-if="activity.activity_type === 'customer_replied'
|
||||
&& activity.activity_metadata?.channel === 'email'"
|
||||
class="visible-tag"
|
||||
>
|
||||
通过邮件回复
|
||||
</span>
|
||||
<span class="time">{{ timeAgo(activity.created_at) }}</span>
|
||||
</div>
|
||||
<div class="item-text" :class="{ muted: activity.actor_type === 'system' }">
|
||||
{{ activity.body || activity.activity_type.replace(/_/g, ' ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="canComment" class="composer">
|
||||
<textarea
|
||||
v-model="draft"
|
||||
class="composer-input"
|
||||
:placeholder="customerVisible ? '回复客户…' : '添加内部备注…'"
|
||||
@keydown.meta.enter="submit"
|
||||
@keydown.ctrl.enter="submit"
|
||||
></textarea>
|
||||
<div class="composer-actions">
|
||||
<label v-if="canMessageCustomer" class="visibility-toggle">
|
||||
<input v-model="customerVisible" type="checkbox" />
|
||||
发送给客户
|
||||
</label>
|
||||
<span v-else class="no-customer-note">内部备注——未关联客户</span>
|
||||
<button class="post-btn" :disabled="!draft.trim() || isSaving" @click="submit">
|
||||
{{ isSaving ? '发布中…' : '评论' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.activity-feed {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.feed-header {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
.feed-body {
|
||||
padding: 6px 20px;
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.feed-empty {
|
||||
padding: 20px 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.feed-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid var(--o05);
|
||||
}
|
||||
.feed-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--c-coral);
|
||||
color: var(--on-accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar.ai {
|
||||
border-radius: 8px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
.avatar.system {
|
||||
border-radius: 8px;
|
||||
background: var(--o08);
|
||||
color: var(--muted);
|
||||
}
|
||||
.item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.author {
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text2);
|
||||
}
|
||||
.ai-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--c-info);
|
||||
background: color-mix(in srgb, var(--c-info) 14%, transparent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.visible-tag {
|
||||
font-size: 10px;
|
||||
color: var(--c-teal);
|
||||
background: var(--teal-bg-10);
|
||||
padding: 1px 7px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.time {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.item-text {
|
||||
font-size: 13px;
|
||||
color: var(--text3);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.item-text.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.composer {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--o07);
|
||||
}
|
||||
.composer-input {
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
resize: vertical;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 10px;
|
||||
padding: 9px 12px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
outline: none;
|
||||
}
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.visibility-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.no-customer-note {
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
}
|
||||
.post-btn {
|
||||
padding: 8px 16px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.post-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { TicketAiState } from '@/types/ticket'
|
||||
import { aiStateMeta } from './ticketMeta'
|
||||
|
||||
const props = defineProps<{ state?: TicketAiState | null }>()
|
||||
const meta = computed(() => aiStateMeta(props.state))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="ai-state" :style="{ '--chip-color': meta.color }">
|
||||
<span class="dot" :class="{ pulse: meta.pulse }"></span>{{ meta.label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--chip-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--chip-color);
|
||||
}
|
||||
.dot.pulse {
|
||||
animation: ticket-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ticket-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,296 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { InvestigationHypothesis, TicketProposal } from '@/types/ticket'
|
||||
import InvestigationNoteForm from './InvestigationNoteForm.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
proposal: TicketProposal
|
||||
hypotheses: InvestigationHypothesis[]
|
||||
canApprove: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'approve'): void
|
||||
(e: 'reject', reason: string, reinvestigate: boolean): void
|
||||
}>()
|
||||
|
||||
const isRejecting = ref(false)
|
||||
const reinvestigate = ref(true)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const bestHypothesis = computed(() => {
|
||||
const validated = props.hypotheses.filter((h) => h.status === 'validated')
|
||||
if (!validated.length) return null
|
||||
return validated.reduce((a, b) => ((a.confidence ?? 0) >= (b.confidence ?? 0) ? a : b))
|
||||
})
|
||||
|
||||
function jumpToHypothesis() {
|
||||
if (!bestHypothesis.value) return
|
||||
document
|
||||
.getElementById(`hypothesis-${bestHypothesis.value.idx}`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
function approve() {
|
||||
isSubmitting.value = true
|
||||
emit('approve')
|
||||
}
|
||||
|
||||
function submitReject(reason: string) {
|
||||
isSubmitting.value = true
|
||||
emit('reject', reason, reinvestigate.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="approval-banner" :class="proposal.status">
|
||||
<!-- PENDING -->
|
||||
<template v-if="proposal.status === 'pending'">
|
||||
<div class="banner-head">
|
||||
<font-awesome-icon :icon="['fas', 'hourglass-half']" class="banner-icon pending" />
|
||||
<div>
|
||||
<div class="banner-title">AI 建议的解决方案——等待你审批</div>
|
||||
<div class="banner-sub">
|
||||
批准后将解决工单,并把下方消息发送给客户。ReplyPier 不会执行任何基础设施变更,修复仍由你的团队完成。
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="proposal.confidence != null" class="confidence-chip mono">
|
||||
{{ proposal.confidence.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="proposal-body">
|
||||
<div class="block-label">建议解决方案</div>
|
||||
<p class="proposal-text">{{ proposal.summary }}</p>
|
||||
<template v-if="proposal.customer_message">
|
||||
<div class="block-label">给客户的消息</div>
|
||||
<p class="proposal-text customer">{{ proposal.customer_message }}</p>
|
||||
</template>
|
||||
<button v-if="bestHypothesis" class="reasoning-link" @click="jumpToHypothesis">
|
||||
推理:H{{ bestHypothesis.idx }} · 已验证
|
||||
<template v-if="bestHypothesis.confidence != null">
|
||||
· 置信度 {{ bestHypothesis.confidence.toFixed(2) }}
|
||||
</template>
|
||||
<font-awesome-icon :icon="['fas', 'arrow-down']" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="canApprove" class="banner-actions">
|
||||
<template v-if="!isRejecting">
|
||||
<button class="reject-btn" :disabled="isSubmitting" @click="isRejecting = true">
|
||||
拒绝…
|
||||
</button>
|
||||
<button class="approve-btn" :disabled="isSubmitting" @click="approve">
|
||||
<font-awesome-icon :icon="['fas', 'check']" />
|
||||
批准并解决
|
||||
</button>
|
||||
</template>
|
||||
<InvestigationNoteForm
|
||||
v-else
|
||||
placeholder="哪里不对?你的备注会指导下一轮调查…"
|
||||
submit-label="拒绝方案"
|
||||
tone="danger"
|
||||
:busy="isSubmitting"
|
||||
@cancel="isRejecting = false"
|
||||
@submit="submitReject"
|
||||
>
|
||||
<label class="reinvestigate-row">
|
||||
<input v-model="reinvestigate" type="checkbox" />
|
||||
根据此反馈重新调查
|
||||
</label>
|
||||
</InvestigationNoteForm>
|
||||
</div>
|
||||
<div v-else class="banner-sub">
|
||||
你没有审批工单上 AI 操作的权限。
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- DECIDED -->
|
||||
<template v-else-if="proposal.status === 'approved'">
|
||||
<div class="banner-head">
|
||||
<font-awesome-icon :icon="['fas', 'circle-check']" class="banner-icon approved" />
|
||||
<div>
|
||||
<div class="banner-title">方案已批准</div>
|
||||
<div class="banner-sub">
|
||||
<template v-if="proposal.decided_by_name">由 {{ proposal.decided_by_name }} 批准——</template>
|
||||
工单已解决并已通知客户。任何基础设施变更都由你的团队执行,而非 ReplyPier。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="proposal.status === 'rejected'">
|
||||
<div class="banner-head">
|
||||
<font-awesome-icon :icon="['fas', 'circle-xmark']" class="banner-icon rejected" />
|
||||
<div>
|
||||
<div class="banner-title">方案已拒绝</div>
|
||||
<div class="banner-sub">
|
||||
<template v-if="proposal.decided_by_name">由 {{ proposal.decided_by_name }} 拒绝。</template>
|
||||
<template v-if="proposal.reject_reason">“{{ proposal.reject_reason }}”</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.approval-banner {
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
border: 1.5px solid var(--c-warn);
|
||||
background: color-mix(in srgb, var(--c-warn) 8%, transparent);
|
||||
}
|
||||
.approval-banner.approved {
|
||||
border-color: var(--c-positive);
|
||||
background: color-mix(in srgb, var(--c-positive) 8%, transparent);
|
||||
}
|
||||
.approval-banner.rejected {
|
||||
border-color: var(--o10);
|
||||
background: var(--surface);
|
||||
}
|
||||
.banner-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
}
|
||||
.banner-icon {
|
||||
font-size: 17px;
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
/* Status colour comes from the design tokens so both themes stay in step —
|
||||
an emoji glyph can't follow the palette. */
|
||||
.banner-icon.pending {
|
||||
color: var(--c-warn);
|
||||
}
|
||||
.banner-icon.approved {
|
||||
color: var(--c-positive);
|
||||
}
|
||||
.banner-icon.rejected {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
.banner-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: 14.5px;
|
||||
color: var(--text);
|
||||
}
|
||||
.banner-sub {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.confidence-chip {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--c-warn);
|
||||
background: color-mix(in srgb, var(--c-warn) 14%, transparent);
|
||||
padding: 3px 9px;
|
||||
border-radius: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.proposal-body {
|
||||
margin-top: 13px;
|
||||
}
|
||||
.block-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
margin: 10px 0 5px;
|
||||
}
|
||||
.proposal-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.proposal-text.customer {
|
||||
color: var(--text3);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.reasoning-link {
|
||||
margin-top: 11px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--c-info);
|
||||
background: color-mix(in srgb, var(--c-info) 10%, transparent);
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.banner-actions {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.banner-actions > .approve-btn,
|
||||
.banner-actions > .reject-btn {
|
||||
margin-right: 0;
|
||||
}
|
||||
.approve-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 9px 17px;
|
||||
background: var(--c-positive);
|
||||
color: var(--on-light, #04140d);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.reject-btn {
|
||||
padding: 9px 15px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--o12);
|
||||
color: var(--text3);
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
margin-right: 9px;
|
||||
}
|
||||
.approve-btn:disabled,
|
||||
.reject-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.reinvestigate-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 9px;
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,718 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { mcpService } from '@/services/mcp'
|
||||
import type { MCPTool, MCPToolReferences, MCPTransportType } from '@/types/mcp'
|
||||
import {
|
||||
CONNECTOR_GUIDANCE_EXAMPLE,
|
||||
CONNECTOR_GUIDANCE_HINT,
|
||||
CONNECTOR_GUIDANCE_LABEL,
|
||||
DEFAULT_MCP_TIMEOUT,
|
||||
SECRET_MASK,
|
||||
clampMCPTimeout,
|
||||
linesToRecord,
|
||||
recordToLines,
|
||||
} from '@/utils/mcp'
|
||||
import GuidanceTextarea from '@/components/common/GuidanceTextarea.vue'
|
||||
import grafanaLogo from '@/assets/grafana-logo.svg'
|
||||
import elasticsearchLogo from '@/assets/elasticsearch-logo.svg'
|
||||
import sentryLogo from '@/assets/sentry-logo.svg'
|
||||
import cloudwatchLogo from '@/assets/cloudwatch-logo.svg'
|
||||
|
||||
const props = defineProps<{ selectedIds: number[] }>()
|
||||
const emit = defineEmits<{ (e: 'update:selected-ids', ids: number[]): void }>()
|
||||
|
||||
const connectors = ref<MCPTool[]>([])
|
||||
const isLoading = ref(true)
|
||||
const loadError = ref<string | null>(null)
|
||||
const isSaving = ref(false)
|
||||
const showForm = ref(false)
|
||||
// Which connector the form is editing, or null when it is creating one.
|
||||
const editingId = ref<number | null>(null)
|
||||
const deleteTarget = ref<MCPTool | null>(null)
|
||||
const deleteRefs = ref<MCPToolReferences | null>(null)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
// One-click starting points for the common observability platforms. All
|
||||
// values are placeholders the user edits — any other platform with an MCP
|
||||
// server works through the Custom tile.
|
||||
const PRESETS = [
|
||||
{
|
||||
key: 'grafana',
|
||||
name: 'Grafana',
|
||||
logo: grafanaLogo,
|
||||
desc: '通过 Grafana 数据源查询 Loki、Prometheus、Elastic 与 CloudWatch',
|
||||
transport: 'http' as MCPTransportType,
|
||||
url: 'http://your-mcp-grafana-host:8000/mcp',
|
||||
envLines: '',
|
||||
headerLines: 'Authorization=Bearer <grafana-service-account-token>',
|
||||
command: '',
|
||||
args: '',
|
||||
},
|
||||
{
|
||||
// Elastic's own MCP endpoint, served by Kibana. The old
|
||||
// @elastic/mcp-server-elasticsearch npm package is deprecated and its last
|
||||
// published version is the deprecated one, so npx would install a dead
|
||||
// package. The API key needs the feature_agentBuilder.read privilege or
|
||||
// Kibana answers 403. Self-hosters below 9.2 can run Elastic's Docker
|
||||
// image in http mode and point this at their own host instead.
|
||||
key: 'elasticsearch',
|
||||
name: 'Elasticsearch',
|
||||
logo: elasticsearchLogo,
|
||||
desc: '通过 Agent Builder 查询索引、映射与日志(Elastic 9.2+ 或 Serverless)',
|
||||
transport: 'http' as MCPTransportType,
|
||||
url: 'https://your-kibana-host/api/agent_builder/mcp',
|
||||
headerLines: 'Authorization=ApiKey <api-key>',
|
||||
command: '',
|
||||
args: '',
|
||||
envLines: '',
|
||||
},
|
||||
{
|
||||
key: 'sentry',
|
||||
name: 'Sentry',
|
||||
logo: sentryLogo,
|
||||
desc: '错误、问题与链路追踪',
|
||||
transport: 'http' as MCPTransportType,
|
||||
url: 'https://mcp.sentry.dev/mcp',
|
||||
headerLines: 'Authorization=Bearer <sentry-token>',
|
||||
command: '',
|
||||
args: '',
|
||||
envLines: '',
|
||||
},
|
||||
{
|
||||
key: 'cloudwatch',
|
||||
name: 'CloudWatch',
|
||||
logo: cloudwatchLogo,
|
||||
desc: 'AWS 日志、指标与告警',
|
||||
transport: 'stdio' as MCPTransportType,
|
||||
url: '',
|
||||
headerLines: '',
|
||||
command: 'uvx',
|
||||
args: 'awslabs.cloudwatch-mcp-server@latest',
|
||||
envLines: 'AWS_ACCESS_KEY_ID=<key>\nAWS_SECRET_ACCESS_KEY=<secret>\nAWS_REGION=us-east-1',
|
||||
},
|
||||
]
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
transport: 'http' as MCPTransportType,
|
||||
url: '',
|
||||
headerLines: '',
|
||||
command: '',
|
||||
args: '',
|
||||
envLines: '',
|
||||
timeout: DEFAULT_MCP_TIMEOUT,
|
||||
usageGuidance: '',
|
||||
})
|
||||
|
||||
function applyPreset(preset: (typeof PRESETS)[0] | null) {
|
||||
Object.assign(form, {
|
||||
name: preset?.name || '',
|
||||
description: preset?.desc || '',
|
||||
transport: preset?.transport || 'http',
|
||||
url: preset?.url || '',
|
||||
headerLines: preset?.headerLines || '',
|
||||
command: preset?.command || '',
|
||||
args: preset?.args || '',
|
||||
envLines: preset?.envLines || '',
|
||||
timeout: DEFAULT_MCP_TIMEOUT,
|
||||
// Never seeded from the preset: its `desc` is a one-line marketing
|
||||
// subtitle, and shipping that as prompt text is the exact failure this
|
||||
// field exists to fix.
|
||||
usageGuidance: '',
|
||||
})
|
||||
editingId.value = null
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
/** Load an existing connector into the same form. Secret values arrive
|
||||
* masked; leaving them alone keeps the stored credential. */
|
||||
function startEdit(connector: MCPTool) {
|
||||
Object.assign(form, {
|
||||
name: connector.name,
|
||||
description: connector.description || '',
|
||||
transport: connector.transport_type,
|
||||
url: connector.url || '',
|
||||
headerLines: recordToLines(connector.headers),
|
||||
command: connector.command || '',
|
||||
args: (connector.args || []).join(' '),
|
||||
envLines: recordToLines(connector.env_vars),
|
||||
timeout: connector.timeout ?? DEFAULT_MCP_TIMEOUT,
|
||||
usageGuidance: connector.usage_guidance || '',
|
||||
})
|
||||
editingId.value = connector.id
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
async function fetchConnectors() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
connectors.value = await mcpService.getOrganizationMCPTools(false)
|
||||
loadError.value = null
|
||||
} catch (err: any) {
|
||||
loadError.value =
|
||||
err.response?.data?.detail || '加载连接器失败——当前套餐可能不支持 MCP 工具。'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** The form's fields as an API payload. Fields belonging to the other
|
||||
* transport are cleared so a switched connector carries no stale command or
|
||||
* URL alongside its new one. */
|
||||
function formPayload() {
|
||||
const isRemote = form.transport !== 'stdio'
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
// Sent even when empty: an omitted key cannot clear a stored description.
|
||||
description: form.description.trim(),
|
||||
transport_type: form.transport,
|
||||
enabled: true,
|
||||
url: isRemote ? form.url.trim() : '',
|
||||
headers: isRemote ? linesToRecord(form.headerLines) : {},
|
||||
command: isRemote ? '' : form.command.trim(),
|
||||
args: isRemote ? [] : form.args.trim().split(/\s+/).filter(Boolean),
|
||||
env_vars: isRemote ? {} : linesToRecord(form.envLines),
|
||||
timeout: clampMCPTimeout(form.timeout),
|
||||
usage_guidance: form.usageGuidance.trim() || null,
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConnector() {
|
||||
if (!form.name.trim()) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (editingId.value !== null) {
|
||||
const updated = await mcpService.updateMCPTool(editingId.value, formPayload())
|
||||
await fetchConnectors()
|
||||
showForm.value = false
|
||||
editingId.value = null
|
||||
toast.success(`${updated.name} 已更新`)
|
||||
return
|
||||
}
|
||||
const created = await mcpService.createMCPTool(formPayload())
|
||||
await fetchConnectors()
|
||||
// New investigation connectors are opted in immediately.
|
||||
emit('update:selected-ids', [...props.selectedIds, created.id])
|
||||
showForm.value = false
|
||||
toast.success(`${created.name} 已连接`)
|
||||
} catch (err: any) {
|
||||
const fallback = editingId.value !== null
|
||||
? '更新连接器失败'
|
||||
: '创建连接器失败'
|
||||
toast.error(err.response?.data?.detail || fallback)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
showForm.value = false
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
/** Ask what points at the connector before offering to delete it, so the
|
||||
* confirmation can name what it is about to break. */
|
||||
async function requestDelete(connector: MCPTool) {
|
||||
deleteTarget.value = connector
|
||||
deleteRefs.value = null
|
||||
try {
|
||||
deleteRefs.value = await mcpService.getMCPToolReferences(connector.id)
|
||||
} catch {
|
||||
// Non-fatal — confirm without the reference list rather than blocking.
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteConnector() {
|
||||
const target = deleteTarget.value
|
||||
if (!target) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await mcpService.deleteMCPTool(target.id)
|
||||
if (editingId.value === target.id) cancelForm()
|
||||
await fetchConnectors()
|
||||
// The API drops the id from the org's selection itself; mirror that
|
||||
// locally only when this connector was actually selected, so deleting an
|
||||
// unselected one costs no settings write.
|
||||
if (props.selectedIds.includes(target.id)) {
|
||||
emit('update:selected-ids', props.selectedIds.filter((id) => id !== target.id))
|
||||
}
|
||||
deleteTarget.value = null
|
||||
toast.success(`${target.name} 已删除`)
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.detail || '删除连接器失败')
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelected(id: number, checked: boolean) {
|
||||
const next = checked
|
||||
? [...props.selectedIds, id]
|
||||
: props.selectedIds.filter((existing) => existing !== id)
|
||||
emit('update:selected-ids', next)
|
||||
}
|
||||
|
||||
onMounted(fetchConnectors)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="connectors">
|
||||
<div class="preset-grid">
|
||||
<button
|
||||
v-for="preset in PRESETS"
|
||||
:key="preset.key"
|
||||
class="preset-tile"
|
||||
@click="applyPreset(preset)"
|
||||
>
|
||||
<img :src="preset.logo" :alt="`${preset.name} 标志`" class="preset-logo" />
|
||||
<span class="preset-name">{{ preset.name }}</span>
|
||||
<span class="preset-desc">{{ preset.desc }}</span>
|
||||
</button>
|
||||
<button class="preset-tile custom" @click="applyPreset(null)">
|
||||
<span class="preset-icon"><font-awesome-icon :icon="['fas', 'plus']" /></span>
|
||||
<span class="preset-name">添加 MCP 连接器</span>
|
||||
<span class="preset-desc">任何提供 MCP 服务器的平台——Splunk、Datadog、New Relic 或自建服务</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showForm" class="create-form">
|
||||
<div v-if="editingId !== null" class="form-title">正在编辑 {{ form.name || '连接器' }}</div>
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span class="field-label">名称</span>
|
||||
<input v-model="form.name" class="field-input" placeholder="Grafana 生产环境" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">传输方式</span>
|
||||
<select v-model="form.transport" class="field-input">
|
||||
<option value="http">HTTP(流式)</option>
|
||||
<option value="sse">SSE</option>
|
||||
<option value="stdio">STDIO(本地命令)</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="form.transport !== 'stdio'">
|
||||
<label class="form-field wide">
|
||||
<span class="field-label">服务器 URL</span>
|
||||
<input v-model="form.url" class="field-input mono" placeholder="https://host/mcp" />
|
||||
</label>
|
||||
<label class="form-field wide">
|
||||
<span class="field-label">请求头(每行一条,KEY=value)</span>
|
||||
<textarea v-model="form.headerLines" class="field-input mono" rows="2"></textarea>
|
||||
<span v-if="editingId !== null" class="field-note">
|
||||
已保存的值显示为 {{ SECRET_MASK }}——保持不变即可沿用现有凭据。
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="form-field">
|
||||
<span class="field-label">命令</span>
|
||||
<input v-model="form.command" class="field-input mono" placeholder="npx" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">参数</span>
|
||||
<input v-model="form.args" class="field-input mono" placeholder="-y @elastic/mcp-server-elasticsearch" />
|
||||
</label>
|
||||
<label class="form-field wide">
|
||||
<span class="field-label">环境变量(每行一条,KEY=value)</span>
|
||||
<textarea v-model="form.envLines" class="field-input mono" rows="3"></textarea>
|
||||
<span v-if="editingId !== null" class="field-note">
|
||||
已保存的值显示为 {{ SECRET_MASK }}——保持不变即可沿用现有凭据。
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
<label class="form-field">
|
||||
<span class="field-label">超时(秒)</span>
|
||||
<input
|
||||
v-model.number="form.timeout"
|
||||
type="number"
|
||||
min="1"
|
||||
max="300"
|
||||
class="field-input"
|
||||
/>
|
||||
<span class="field-note">握手与单次调用上限。首次启动较慢时可调大。</span>
|
||||
</label>
|
||||
<div class="form-field wide">
|
||||
<GuidanceTextarea
|
||||
v-model="form.usageGuidance"
|
||||
:label="CONNECTOR_GUIDANCE_LABEL"
|
||||
:hint="CONNECTOR_GUIDANCE_HINT"
|
||||
:example="CONNECTOR_GUIDANCE_EXAMPLE"
|
||||
:rows="5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="cancel-btn" @click="cancelForm">取消</button>
|
||||
<button class="connect-btn" :disabled="isSaving || !form.name.trim()" @click="saveConnector">
|
||||
<template v-if="editingId !== null">{{ isSaving ? '保存中…' : '保存更改' }}</template>
|
||||
<template v-else>{{ isSaving ? '连接中…' : '连接' }}</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="state-note">加载连接器…</div>
|
||||
<div v-else-if="loadError" class="state-note">{{ loadError }}</div>
|
||||
<div v-else-if="connectors.length" class="connector-list">
|
||||
<div v-for="connector in connectors" :key="connector.id" class="connector-row">
|
||||
<label class="connector-select">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.includes(connector.id)"
|
||||
:disabled="!connector.enabled"
|
||||
@change="toggleSelected(connector.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<div class="connector-info">
|
||||
<div class="connector-name">
|
||||
{{ connector.name }}
|
||||
<span class="transport-tag mono">{{ connector.transport_type }}</span>
|
||||
<span v-if="!connector.enabled" class="disabled-tag mono">已禁用</span>
|
||||
<span v-if="connector.usage_guidance" class="guided-tag mono" title="AI 已为此数据源配置指导">已配置指导</span>
|
||||
</div>
|
||||
<div class="connector-sub mono">
|
||||
{{ connector.url || [connector.command, ...(connector.args || [])].join(' ') }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="use-label">用于调查</span>
|
||||
</label>
|
||||
<div class="row-actions">
|
||||
<button class="row-btn" @click="startEdit(connector)">编辑</button>
|
||||
<button class="row-btn danger" @click="requestDelete(connector)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="state-note">
|
||||
暂无连接器——请在上方连接一个,AI 才能从你的日志和指标中收集证据。
|
||||
</div>
|
||||
|
||||
<div v-if="deleteTarget" class="confirm-overlay" @click.self="deleteTarget = null">
|
||||
<div class="confirm-box">
|
||||
<h4 class="confirm-title">删除 {{ deleteTarget.name }}?</h4>
|
||||
<p class="confirm-text">
|
||||
其保存的凭据会一并删除,且无法恢复。
|
||||
</p>
|
||||
<ul v-if="deleteRefs && (deleteRefs.agents.length || deleteRefs.used_in_investigations)" class="confirm-refs">
|
||||
<li v-if="deleteRefs.used_in_investigations">
|
||||
它已被选用于调查——删除后将取消选择。
|
||||
</li>
|
||||
<li v-if="deleteRefs.agents.length">
|
||||
已关联到 {{ deleteRefs.agents.join(', ') }}——该关联将被移除。
|
||||
</li>
|
||||
</ul>
|
||||
<div class="form-actions">
|
||||
<button class="cancel-btn" @click="deleteTarget = null">取消</button>
|
||||
<button class="danger-btn" :disabled="isDeleting" @click="deleteConnector">
|
||||
{{ isDeleting ? '删除中…' : '删除连接器' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lock-note">
|
||||
<font-awesome-icon :icon="['fas', 'lock']" />
|
||||
只读——连接器仅挂载到<strong>调查智能体</strong>,绝不用在面向客户的聊天智能体上。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.connectors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.preset-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
padding: 13px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.preset-tile:hover {
|
||||
border-color: var(--accent-ink);
|
||||
}
|
||||
.preset-tile.custom {
|
||||
border-style: dashed;
|
||||
}
|
||||
.preset-logo {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.preset-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.preset-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.preset-desc {
|
||||
font-size: 10.5px;
|
||||
line-height: 1.4;
|
||||
color: var(--faint);
|
||||
}
|
||||
.create-form {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 13px;
|
||||
padding: 15px 16px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 11px;
|
||||
}
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.form-field.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
}
|
||||
.field-note {
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.field-input {
|
||||
padding: 8px 11px;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 9px;
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 9px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.cancel-btn {
|
||||
padding: 7px 13px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.connect-btn {
|
||||
padding: 7px 16px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.connect-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.state-note {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
padding: 12px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o07);
|
||||
border-radius: 11px;
|
||||
}
|
||||
.connector-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.connector-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 11px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 11px;
|
||||
}
|
||||
.connector-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.row-btn {
|
||||
padding: 5px 11px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.row-btn.danger:hover {
|
||||
color: var(--c-danger);
|
||||
border-color: var(--c-danger);
|
||||
}
|
||||
.form-title {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
background: var(--scrim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.confirm-box {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 13px;
|
||||
padding: 18px 20px;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
.confirm-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.confirm-text {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
.confirm-refs {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.danger-btn {
|
||||
padding: 7px 16px;
|
||||
background: var(--c-danger);
|
||||
color: var(--on-dark);
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.danger-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.connector-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.connector-name {
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.transport-tag {
|
||||
font-size: 9.5px;
|
||||
color: var(--c-teal);
|
||||
background: var(--teal-bg-10);
|
||||
padding: 1px 7px;
|
||||
border-radius: 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.disabled-tag,
|
||||
.guided-tag {
|
||||
font-size: 9.5px;
|
||||
background: var(--o05);
|
||||
padding: 1px 7px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.disabled-tag {
|
||||
color: var(--faint);
|
||||
}
|
||||
.guided-tag {
|
||||
color: var(--muted);
|
||||
}
|
||||
.connector-sub {
|
||||
font-size: 10.5px;
|
||||
color: var(--faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.use-label {
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lock-note {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,425 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { ticketService } from '@/services/tickets'
|
||||
import { peopleService } from '@/services/people'
|
||||
import type { PersonListItem } from '@/types/people'
|
||||
import type { TicketDetail, TicketPriority } from '@/types/ticket'
|
||||
import { PRIORITIES, priorityMeta } from './ticketMeta'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
// Present when creating from a conversation: enables the AI-drafted prefill.
|
||||
sessionId?: string | null
|
||||
sessionLabel?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'created', detail: TicketDetail): void
|
||||
}>()
|
||||
|
||||
const title = ref('')
|
||||
const description = ref('')
|
||||
const priority = ref<TicketPriority>('medium')
|
||||
const fromConversation = ref(false)
|
||||
const isDrafting = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
// Manual tickets have no conversation to deliver through — collecting an
|
||||
// email gives notifications a direct-email path. Suggestions come from the
|
||||
// org's identified People.
|
||||
const customerEmail = ref('')
|
||||
const customerName = ref('')
|
||||
const customerSuggestions = ref<PersonListItem[]>([])
|
||||
let suggestTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(customerEmail, (query) => {
|
||||
if (suggestTimer) clearTimeout(suggestTimer)
|
||||
const term = query.trim()
|
||||
if (term.length < 2) {
|
||||
customerSuggestions.value = []
|
||||
return
|
||||
}
|
||||
const matched = customerSuggestions.value.find((p) => p.email === term)
|
||||
if (matched && !customerName.value) customerName.value = matched.name || ''
|
||||
suggestTimer = setTimeout(async () => {
|
||||
try {
|
||||
const response = await peopleService.listPeople({ search: term, page_size: 8 })
|
||||
customerSuggestions.value = response.items.filter((p) => p.email)
|
||||
} catch {
|
||||
customerSuggestions.value = []
|
||||
}
|
||||
}, 300)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (suggestTimer) clearTimeout(suggestTimer)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
title.value = ''
|
||||
description.value = ''
|
||||
priority.value = 'medium'
|
||||
fromConversation.value = false
|
||||
customerEmail.value = ''
|
||||
customerName.value = ''
|
||||
customerSuggestions.value = []
|
||||
if (props.sessionId) useConversationDraft()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function useConversationDraft() {
|
||||
if (!props.sessionId) return
|
||||
fromConversation.value = true
|
||||
isDrafting.value = true
|
||||
try {
|
||||
const draft = await ticketService.draftFromSession(props.sessionId)
|
||||
if (!title.value) title.value = draft.title
|
||||
if (!description.value) description.value = draft.description
|
||||
} catch {
|
||||
// Draft is best-effort; the form still works blank.
|
||||
} finally {
|
||||
isDrafting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!title.value.trim() || isSubmitting.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const linkSession = fromConversation.value && props.sessionId
|
||||
const detail = await ticketService.createTicket({
|
||||
title: title.value.trim(),
|
||||
description: description.value.trim() || undefined,
|
||||
priority: priority.value,
|
||||
session_id: linkSession ? props.sessionId! : undefined,
|
||||
customer_email: !linkSession ? customerEmail.value.trim() || undefined : undefined,
|
||||
customer_name: !linkSession ? customerName.value.trim() || undefined : undefined,
|
||||
})
|
||||
toast.success(`工单 ${detail.ticket.display_number} 已创建`)
|
||||
emit('created', detail)
|
||||
emit('close')
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '创建工单失败')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="modal-overlay" @click="emit('close')">
|
||||
<div class="modal" @click.stop>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">新建工单</div>
|
||||
<button class="close-btn" @click="emit('close')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div v-if="sessionId" class="source-toggle">
|
||||
<button
|
||||
class="source-option"
|
||||
:class="{ active: !fromConversation }"
|
||||
@click="fromConversation = false"
|
||||
>
|
||||
空白
|
||||
</button>
|
||||
<button
|
||||
class="source-option"
|
||||
:class="{ active: fromConversation }"
|
||||
@click="useConversationDraft"
|
||||
>
|
||||
来自会话
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="fromConversation" class="draft-note">
|
||||
<span class="draft-glyph">✎</span>
|
||||
<span v-if="isDrafting">正在根据会话生成草稿…</span>
|
||||
<span v-else>
|
||||
已根据
|
||||
<span class="mono">{{ sessionLabel || '该会话' }}</span>
|
||||
生成草稿——可随意修改。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label class="field-label">主题</label>
|
||||
<input
|
||||
v-model="title"
|
||||
class="field-input"
|
||||
placeholder="简要描述问题…"
|
||||
maxlength="500"
|
||||
/>
|
||||
|
||||
<label class="field-label">描述</label>
|
||||
<textarea
|
||||
v-model="description"
|
||||
class="field-textarea"
|
||||
placeholder="发生了什么?请说明报错、对客户的影响和复现步骤…"
|
||||
></textarea>
|
||||
|
||||
<template v-if="!fromConversation">
|
||||
<label class="field-label">客户邮箱 <span class="optional">(选填,用于通知客户)</span></label>
|
||||
<input
|
||||
v-model="customerEmail"
|
||||
class="field-input"
|
||||
type="email"
|
||||
list="ticket-customer-suggestions"
|
||||
placeholder="customer@company.com"
|
||||
maxlength="320"
|
||||
/>
|
||||
<datalist id="ticket-customer-suggestions">
|
||||
<option
|
||||
v-for="person in customerSuggestions"
|
||||
:key="person.id"
|
||||
:value="person.email || ''"
|
||||
>
|
||||
{{ person.name || person.email }}
|
||||
</option>
|
||||
</datalist>
|
||||
|
||||
<template v-if="customerEmail.trim()">
|
||||
<label class="field-label">客户名称 <span class="optional">(选填)</span></label>
|
||||
<input
|
||||
v-model="customerName"
|
||||
class="field-input"
|
||||
placeholder="Jane Doe"
|
||||
maxlength="200"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<label class="field-label">优先级</label>
|
||||
<div class="priority-pills">
|
||||
<button
|
||||
v-for="p in PRIORITIES"
|
||||
:key="p"
|
||||
class="priority-pill"
|
||||
:class="{ active: priority === p }"
|
||||
:style="{ '--pill-color': priorityMeta(p).color }"
|
||||
@click="priority = p"
|
||||
>
|
||||
<span class="dot"></span>{{ priorityMeta(p).label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="emit('close')">取消</button>
|
||||
<button class="btn-primary" :disabled="!title.trim() || isSubmitting" @click="submit">
|
||||
{{ isSubmitting ? '创建中…' : '创建工单' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--scrim, rgba(4, 5, 8, 0.62));
|
||||
backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 8vh 20px 20px;
|
||||
}
|
||||
.modal {
|
||||
width: 560px;
|
||||
max-width: 100%;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
}
|
||||
.modal-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: 17px;
|
||||
color: var(--text);
|
||||
}
|
||||
.close-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
.modal-body {
|
||||
padding: 22px;
|
||||
}
|
||||
.source-toggle {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
padding: 3px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.source-option {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
.source-option.active {
|
||||
background: var(--o08);
|
||||
color: var(--text);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
.draft-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 13px;
|
||||
background: var(--accent-bg-08);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 11px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.draft-glyph {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text);
|
||||
}
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 11.5px;
|
||||
color: var(--faint);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.optional {
|
||||
color: var(--muted2);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
.field-input,
|
||||
.field-textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
font-size: 13.5px;
|
||||
outline: none;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.field-textarea {
|
||||
min-height: 96px;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.priority-pills {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.priority-pill {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--o10);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
}
|
||||
.priority-pill .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 2px;
|
||||
background: var(--pill-color);
|
||||
}
|
||||
.priority-pill.active {
|
||||
border-color: var(--pill-color);
|
||||
color: var(--pill-color);
|
||||
background: var(--o05);
|
||||
}
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px 22px;
|
||||
border-top: 1px solid var(--o07);
|
||||
background: var(--surface);
|
||||
}
|
||||
.btn-secondary {
|
||||
padding: 9px 17px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--text);
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary {
|
||||
padding: 9px 20px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,889 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { dbConnectorService } from '@/services/tickets'
|
||||
import type { DbConnector, DbConnectorTable } from '@/types/ticket'
|
||||
|
||||
const connectors = ref<DbConnector[]>([])
|
||||
const isLoading = ref(true)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
const showForm = ref(false)
|
||||
// When set, the picker edits this saved connector instead of the draft form.
|
||||
const editingConnector = ref<DbConnector | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
engine: 'postgresql' as 'postgresql' | 'mysql',
|
||||
host: '',
|
||||
port: 5432,
|
||||
database: '',
|
||||
username: '',
|
||||
password: '',
|
||||
max_rows: 100,
|
||||
// SSH tunnel — production databases are usually behind a bastion.
|
||||
ssh_enabled: false,
|
||||
ssh_host: '',
|
||||
ssh_port: 22,
|
||||
ssh_username: '',
|
||||
ssh_auth: 'key' as 'key' | 'password',
|
||||
ssh_password: '',
|
||||
ssh_private_key: '',
|
||||
ssh_private_key_passphrase: '',
|
||||
})
|
||||
|
||||
// Only the tunnel fields the API accepts, with the chosen auth method.
|
||||
function sshPayload() {
|
||||
if (!form.ssh_enabled) return { ssh_enabled: false }
|
||||
const base = {
|
||||
ssh_enabled: true,
|
||||
ssh_host: form.ssh_host.trim(),
|
||||
ssh_port: form.ssh_port,
|
||||
ssh_username: form.ssh_username.trim(),
|
||||
}
|
||||
return form.ssh_auth === 'password'
|
||||
? { ...base, ssh_password: form.ssh_password || undefined }
|
||||
: {
|
||||
...base,
|
||||
ssh_private_key: form.ssh_private_key || undefined,
|
||||
ssh_private_key_passphrase: form.ssh_private_key_passphrase || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const isDiscovering = ref(false)
|
||||
const discoveredTables = ref<DbConnectorTable[] | null>(null)
|
||||
const discoverError = ref<string | null>(null)
|
||||
const selectedTables = ref<Set<string>>(new Set())
|
||||
const maskedColumns = ref<Set<string>>(new Set())
|
||||
// { "schema.table": "customer_column" } — a scoped table only ever returns the
|
||||
// ticket customer's own rows. Absent = readable in full.
|
||||
const rowScope = ref<Record<string, string>>({})
|
||||
const rowScopeKey = ref<'email' | 'phone'>('email')
|
||||
const expandedTable = ref<string | null>(null)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const scopedTableCount = computed(() => Object.keys(rowScope.value).length)
|
||||
|
||||
function setRowScope(table: DbConnectorTable, column: string) {
|
||||
const next = { ...rowScope.value }
|
||||
if (column) next[tableKey(table)] = column
|
||||
else delete next[tableKey(table)]
|
||||
rowScope.value = next
|
||||
}
|
||||
|
||||
const tablesBySchema = computed(() => {
|
||||
const groups: Record<string, DbConnectorTable[]> = {}
|
||||
for (const table of discoveredTables.value || []) {
|
||||
;(groups[table.schema] ||= []).push(table)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function tableKey(table: DbConnectorTable): string {
|
||||
return `${table.schema}.${table.table}`.toLowerCase()
|
||||
}
|
||||
|
||||
function resetPicker() {
|
||||
discoveredTables.value = null
|
||||
discoverError.value = null
|
||||
selectedTables.value = new Set()
|
||||
maskedColumns.value = new Set()
|
||||
rowScope.value = {}
|
||||
rowScopeKey.value = 'email'
|
||||
expandedTable.value = null
|
||||
}
|
||||
|
||||
function openCreateForm() {
|
||||
editingConnector.value = null
|
||||
Object.assign(form, {
|
||||
name: '', engine: 'postgresql', host: '', port: 5432,
|
||||
database: '', username: '', password: '', max_rows: 100,
|
||||
ssh_enabled: false, ssh_host: '', ssh_port: 22, ssh_username: '',
|
||||
ssh_auth: 'key', ssh_password: '', ssh_private_key: '', ssh_private_key_passphrase: '',
|
||||
})
|
||||
resetPicker()
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
async function fetchConnectors() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
connectors.value = await dbConnectorService.list()
|
||||
loadError.value = null
|
||||
} catch (err: any) {
|
||||
loadError.value = err.response?.data?.detail || '加载数据库连接器失败'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function discover() {
|
||||
isDiscovering.value = true
|
||||
discoverError.value = null
|
||||
try {
|
||||
const result = editingConnector.value
|
||||
? await dbConnectorService.test(editingConnector.value.id)
|
||||
: await dbConnectorService.discover({
|
||||
name: form.name, engine: form.engine, host: form.host, port: form.port,
|
||||
database: form.database, username: form.username, password: form.password,
|
||||
...sshPayload(),
|
||||
})
|
||||
if (!result.ok) {
|
||||
discoverError.value = result.error || '连接失败'
|
||||
discoveredTables.value = null
|
||||
} else {
|
||||
discoveredTables.value = result.tables
|
||||
toast.success(`已连接——发现 ${result.tables.length} 张表`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
discoverError.value = err.response?.data?.detail || '连接失败'
|
||||
} finally {
|
||||
isDiscovering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTable(table: DbConnectorTable, checked: boolean) {
|
||||
const next = new Set(selectedTables.value)
|
||||
if (checked) {
|
||||
next.add(tableKey(table))
|
||||
} else {
|
||||
next.delete(tableKey(table))
|
||||
// Don't leave a scope rule behind on a table that is no longer allowed —
|
||||
// re-adding it later would silently restore a filter nobody chose.
|
||||
setRowScope(table, '')
|
||||
}
|
||||
selectedTables.value = next
|
||||
}
|
||||
|
||||
function toggleMask(columnName: string, masked: boolean) {
|
||||
const next = new Set(maskedColumns.value)
|
||||
if (masked) next.add(columnName.toLowerCase())
|
||||
else next.delete(columnName.toLowerCase())
|
||||
maskedColumns.value = next
|
||||
}
|
||||
|
||||
async function saveConnector() {
|
||||
if (!selectedTables.value.size) {
|
||||
toast.error('请至少选择一张表——否则无法查询')
|
||||
return
|
||||
}
|
||||
isSaving.value = true
|
||||
try {
|
||||
const policy = {
|
||||
allowed_tables: [...selectedTables.value],
|
||||
masked_columns: [...maskedColumns.value],
|
||||
row_scope: rowScope.value,
|
||||
row_scope_key: rowScopeKey.value,
|
||||
max_rows: form.max_rows,
|
||||
}
|
||||
if (editingConnector.value) {
|
||||
await dbConnectorService.update(editingConnector.value.id, policy)
|
||||
toast.success('连接器已更新')
|
||||
} else {
|
||||
await dbConnectorService.create({
|
||||
name: form.name, engine: form.engine, host: form.host, port: form.port,
|
||||
database: form.database, username: form.username, password: form.password,
|
||||
enabled: true, ...sshPayload(), ...policy,
|
||||
})
|
||||
toast.success('数据库连接器已创建')
|
||||
}
|
||||
showForm.value = false
|
||||
editingConnector.value = null
|
||||
resetPicker()
|
||||
await fetchConnectors()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.detail || '保存连接器失败')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function editTables(connector: DbConnector) {
|
||||
editingConnector.value = connector
|
||||
form.max_rows = connector.max_rows
|
||||
resetPicker()
|
||||
selectedTables.value = new Set((connector.allowed_tables || []).map((t) => t.toLowerCase()))
|
||||
maskedColumns.value = new Set((connector.masked_columns || []).map((c) => c.toLowerCase()))
|
||||
rowScope.value = { ...(connector.row_scope || {}) }
|
||||
rowScopeKey.value = connector.row_scope_key || 'email'
|
||||
showForm.value = true
|
||||
await discover()
|
||||
}
|
||||
|
||||
async function toggleEnabled(connector: DbConnector, enabled: boolean) {
|
||||
try {
|
||||
await dbConnectorService.update(connector.id, { enabled })
|
||||
await fetchConnectors()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.detail || '更新连接器失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeConnector(connector: DbConnector) {
|
||||
if (!confirm(`删除连接器“${connector.name}”?AI 将失去此数据库的访问权限。`)) return
|
||||
try {
|
||||
await dbConnectorService.remove(connector.id)
|
||||
await fetchConnectors()
|
||||
toast.success('连接器已删除')
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.detail || '删除连接器失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchConnectors)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="db-connectors">
|
||||
<div v-if="isLoading" class="state-note">加载中…</div>
|
||||
<div v-else-if="loadError" class="state-note">{{ loadError }}</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="connectors.length" class="connector-list">
|
||||
<div v-for="connector in connectors" :key="connector.id" class="connector-card">
|
||||
<div class="connector-main">
|
||||
<div class="connector-name">
|
||||
{{ connector.name }}
|
||||
<span class="engine-tag mono">{{ connector.engine }}</span>
|
||||
<span
|
||||
v-if="connector.last_test_ok != null"
|
||||
class="test-tag mono"
|
||||
:class="{ ok: connector.last_test_ok }"
|
||||
>
|
||||
{{ connector.last_test_ok ? '已连接' : '连接失败' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="connector-sub mono">
|
||||
{{ connector.username }}@{{ connector.host }}:{{ connector.port }}/{{ connector.database }}
|
||||
· {{ (connector.allowed_tables || []).length }} 张表可用
|
||||
· {{ (connector.masked_columns || []).length }} 列已脱敏
|
||||
· {{ Object.keys(connector.row_scope || {}).length }} 张按客户限定
|
||||
</div>
|
||||
</div>
|
||||
<label class="enable-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="connector.enabled"
|
||||
@change="toggleEnabled(connector, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<button class="small-btn" @click="editTables(connector)">编辑数据表</button>
|
||||
<button class="small-btn danger" @click="removeConnector(connector)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button v-if="!showForm" class="add-connector" @click="openCreateForm">
|
||||
+ 连接数据库
|
||||
</button>
|
||||
|
||||
<div v-if="showForm" class="create-form">
|
||||
<template v-if="!editingConnector">
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span class="field-label">名称</span>
|
||||
<input v-model="form.name" class="field-input" placeholder="生产只读副本" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">引擎</span>
|
||||
<select
|
||||
v-model="form.engine"
|
||||
class="field-input"
|
||||
@change="form.port = form.engine === 'mysql' ? 3306 : 5432"
|
||||
>
|
||||
<option value="postgresql">PostgreSQL</option>
|
||||
<option value="mysql">MySQL</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">主机</span>
|
||||
<input v-model="form.host" class="field-input mono" placeholder="db-replica.internal" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">端口</span>
|
||||
<input v-model.number="form.port" type="number" class="field-input mono" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">数据库</span>
|
||||
<input v-model="form.database" class="field-input mono" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">用户名</span>
|
||||
<input v-model="form.username" class="field-input mono" placeholder="readonly_user" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">密码</span>
|
||||
<input v-model="form.password" type="password" class="field-input mono" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">单次查询最大行数</span>
|
||||
<input v-model.number="form.max_rows" type="number" min="1" max="1000" class="field-input mono" />
|
||||
</label>
|
||||
</div>
|
||||
<!-- SSH TUNNEL -->
|
||||
<div class="ssh-block">
|
||||
<label class="ssh-toggle">
|
||||
<input v-model="form.ssh_enabled" type="checkbox" />
|
||||
<span>
|
||||
通过 SSH 隧道连接
|
||||
<span class="ssh-hint">——适用于位于堡垒机 / 跳板机之后的数据库(生产环境常见)</span>
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="form.ssh_enabled" class="ssh-fields">
|
||||
<p class="ssh-note">
|
||||
上方的主机和端口将从 SSH 主机的网络中解析。
|
||||
</p>
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span class="field-label">SSH 主机</span>
|
||||
<input v-model="form.ssh_host" class="field-input mono" placeholder="bastion.example.com" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">SSH 端口</span>
|
||||
<input v-model.number="form.ssh_port" type="number" class="field-input mono" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">SSH 用户名</span>
|
||||
<input v-model="form.ssh_username" class="field-input mono" placeholder="ec2-user" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">认证方式</span>
|
||||
<select v-model="form.ssh_auth" class="field-input">
|
||||
<option value="key">私钥</option>
|
||||
<option value="password">密码</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.ssh_auth === 'password'" class="form-field wide">
|
||||
<span class="field-label">SSH 密码</span>
|
||||
<input v-model="form.ssh_password" type="password" class="field-input mono" />
|
||||
</label>
|
||||
<template v-else>
|
||||
<label class="form-field wide">
|
||||
<span class="field-label">私钥(PEM / OpenSSH)</span>
|
||||
<textarea
|
||||
v-model="form.ssh_private_key"
|
||||
class="field-input mono key-input"
|
||||
rows="4"
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- …"
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">私钥口令(选填)</span>
|
||||
<input v-model="form.ssh_private_key_passphrase" type="password" class="field-input mono" />
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="tip-note">
|
||||
提示:使用只读副本,并只为数据库用户授予 SELECT 权限——在 ReplyPier 自身的防护之外再加一道保险。
|
||||
</p>
|
||||
</template>
|
||||
<div v-else class="editing-note">
|
||||
正在编辑 <strong>{{ editingConnector.name }}</strong> 的表访问权限
|
||||
</div>
|
||||
|
||||
<div class="discover-row">
|
||||
<button
|
||||
class="small-btn"
|
||||
:disabled="isDiscovering"
|
||||
@click="discover"
|
||||
>
|
||||
{{ isDiscovering ? '连接中…' : discoveredTables ? '重新测试连接' : '测试连接' }}
|
||||
</button>
|
||||
<span v-if="discoveredTables" class="discover-result">
|
||||
<font-awesome-icon :icon="['fas', 'check']" />
|
||||
发现 {{ discoveredTables.length }} 张表——请选择 AI 可查询的表
|
||||
</span>
|
||||
<span v-else-if="discoverError" class="discover-error">{{ discoverError }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="discoveredTables && scopedTableCount" class="scope-key-row">
|
||||
<label class="scope-label">
|
||||
客户匹配依据
|
||||
<select v-model="rowScopeKey" class="scope-select">
|
||||
<option value="email">邮箱地址</option>
|
||||
<option value="phone">手机号</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="scope-key-hint">
|
||||
<template v-if="rowScopeKey === 'email'">
|
||||
工单客户的邮箱会与你为各表指定的列进行匹配,忽略大小写。
|
||||
</template>
|
||||
<template v-else>
|
||||
工单客户的手机号会与你为各表指定的列精确匹配,请确保两边存储格式一致。
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ALLOWLIST TREE -->
|
||||
<div v-if="discoveredTables" class="table-tree">
|
||||
<div v-for="(tables, schema) in tablesBySchema" :key="schema" class="schema-group">
|
||||
<div class="schema-label mono">{{ schema }}</div>
|
||||
<div v-for="table in tables" :key="tableKey(table)" class="table-row-wrap">
|
||||
<div class="table-row">
|
||||
<label class="table-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedTables.has(tableKey(table))"
|
||||
@change="toggleTable(table, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="table-name mono">{{ table.table }}</span>
|
||||
<span class="col-count">{{ table.columns.length }} 列</span>
|
||||
<span v-if="rowScope[tableKey(table)]" class="scope-chip mono">
|
||||
<font-awesome-icon :icon="['fas', 'user-shield']" />
|
||||
{{ rowScope[tableKey(table)] }}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
v-if="selectedTables.has(tableKey(table))"
|
||||
class="mask-toggle"
|
||||
@click="expandedTable = expandedTable === tableKey(table) ? null : tableKey(table)"
|
||||
>
|
||||
{{ expandedTable === tableKey(table) ? '隐藏列' : '列脱敏' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="expandedTable === tableKey(table)" class="column-list">
|
||||
<div v-for="column in table.columns" :key="column.name" class="column-row">
|
||||
<span class="column-name mono">{{ column.name }}</span>
|
||||
<span class="column-type">{{ column.type }}</span>
|
||||
<button
|
||||
class="mask-pill"
|
||||
:class="{ masked: maskedColumns.has(column.name.toLowerCase()) }"
|
||||
@click="toggleMask(column.name, !maskedColumns.has(column.name.toLowerCase()))"
|
||||
>
|
||||
{{ maskedColumns.has(column.name.toLowerCase()) ? '已脱敏' : '可见' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mask-hint">
|
||||
脱敏列在 AI 看到之前就会被隐藏——引用这些列的查询会被直接拒绝。脱敏按列名在此连接器内全局生效。
|
||||
</p>
|
||||
|
||||
<div class="scope-row">
|
||||
<label class="scope-label">
|
||||
限定为该工单客户的数据
|
||||
<select
|
||||
class="scope-select mono"
|
||||
:value="rowScope[tableKey(table)] || ''"
|
||||
@change="setRowScope(table, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">不限定——可读取全部行</option>
|
||||
<option v-for="column in table.columns" :key="column.name" :value="column.name">
|
||||
{{ column.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="mask-hint">
|
||||
选择存放客户标识的列。AI 的查询会被改写为仅读取该客户的行——无法扩大范围,且未关联客户的工单完全无法查询此表。与具体客户无关的参考数据可不限定。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="cancel-btn" @click="showForm = false; editingConnector = null">取消</button>
|
||||
<button
|
||||
v-if="discoveredTables"
|
||||
class="save-btn"
|
||||
:disabled="isSaving"
|
||||
@click="saveConnector"
|
||||
>
|
||||
{{ isSaving ? '保存中…' : editingConnector ? '保存更改' : '保存连接器' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.db-connectors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.state-note {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
padding: 12px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o07);
|
||||
border-radius: 11px;
|
||||
}
|
||||
.connector-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.connector-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 15px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.connector-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.connector-name {
|
||||
font-size: 13.5px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.engine-tag {
|
||||
font-size: 9.5px;
|
||||
color: var(--c-teal);
|
||||
background: var(--teal-bg-10);
|
||||
padding: 1px 7px;
|
||||
border-radius: 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.test-tag {
|
||||
font-size: 9.5px;
|
||||
color: var(--c-danger);
|
||||
background: color-mix(in srgb, var(--c-danger) 10%, transparent);
|
||||
padding: 1px 7px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.test-tag.ok {
|
||||
color: var(--c-positive);
|
||||
background: color-mix(in srgb, var(--c-positive) 10%, transparent);
|
||||
}
|
||||
.connector-sub {
|
||||
margin-top: 3px;
|
||||
font-size: 10.5px;
|
||||
color: var(--faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.enable-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.small-btn {
|
||||
padding: 6px 12px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.small-btn.danger {
|
||||
color: var(--c-danger);
|
||||
border-color: color-mix(in srgb, var(--c-danger) 35%, transparent);
|
||||
}
|
||||
.small-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.add-connector {
|
||||
align-self: flex-start;
|
||||
padding: 9px 16px;
|
||||
background: transparent;
|
||||
border: 1.5px dashed var(--o14);
|
||||
color: var(--muted);
|
||||
border-radius: 11px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.add-connector:hover {
|
||||
border-color: var(--accent-ink);
|
||||
color: var(--text);
|
||||
}
|
||||
.create-form {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 13px;
|
||||
padding: 16px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 11px;
|
||||
}
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
}
|
||||
.field-input {
|
||||
padding: 8px 11px;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 9px;
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
outline: none;
|
||||
}
|
||||
.tip-note {
|
||||
margin: 12px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ssh-block {
|
||||
margin-top: 14px;
|
||||
border-top: 1px solid var(--o07);
|
||||
padding-top: 13px;
|
||||
}
|
||||
.ssh-toggle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
font-size: 13px;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ssh-hint {
|
||||
color: var(--faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ssh-fields {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.ssh-note {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11.5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
.key-input {
|
||||
resize: vertical;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.editing-note {
|
||||
font-size: 13px;
|
||||
color: var(--text3);
|
||||
}
|
||||
.discover-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.discover-result {
|
||||
font-size: 12px;
|
||||
color: var(--c-positive);
|
||||
}
|
||||
.discover-error {
|
||||
font-size: 12px;
|
||||
color: var(--c-danger);
|
||||
}
|
||||
.table-tree {
|
||||
margin-top: 13px;
|
||||
border: 1px solid var(--o07);
|
||||
border-radius: 11px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.schema-group {
|
||||
padding: 6px 0;
|
||||
}
|
||||
.schema-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
padding: 6px 14px 4px;
|
||||
}
|
||||
.table-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 14px;
|
||||
}
|
||||
.table-row:hover {
|
||||
background: var(--o03);
|
||||
}
|
||||
.table-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
.table-name {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
.col-count {
|
||||
font-size: 10.5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
.mask-toggle {
|
||||
font-size: 11px;
|
||||
color: var(--accent-ink);
|
||||
background: var(--accent-bg-08);
|
||||
border: none;
|
||||
padding: 3px 9px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.column-list {
|
||||
margin: 4px 14px 10px 34px;
|
||||
border-left: 2px solid var(--o07);
|
||||
padding-left: 12px;
|
||||
}
|
||||
.column-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.column-name {
|
||||
font-size: 11.5px;
|
||||
color: var(--text3);
|
||||
min-width: 140px;
|
||||
}
|
||||
.column-type {
|
||||
font-size: 10.5px;
|
||||
color: var(--faint);
|
||||
flex: 1;
|
||||
}
|
||||
.mask-pill {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--o10);
|
||||
background: transparent;
|
||||
color: var(--muted2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mask-pill.masked {
|
||||
color: var(--c-warn);
|
||||
border-color: var(--c-warn);
|
||||
background: color-mix(in srgb, var(--c-warn) 10%, transparent);
|
||||
}
|
||||
.mask-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 10.5px;
|
||||
color: var(--faint);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.scope-row {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--o08);
|
||||
}
|
||||
.scope-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
.scope-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 6px 9px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.scope-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 20px;
|
||||
font-size: 10px;
|
||||
color: var(--c-teal);
|
||||
background: color-mix(in srgb, var(--c-teal) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--c-teal) 30%, transparent);
|
||||
}
|
||||
.scope-key-row {
|
||||
margin: 14px 0 4px;
|
||||
padding: 11px 13px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.scope-key-hint {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
font-size: 10.5px;
|
||||
color: var(--faint);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 9px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.cancel-btn {
|
||||
padding: 8px 14px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.save-btn {
|
||||
padding: 8px 17px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.save-btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.connector-card {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ filtered: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'clear'): void; (e: 'create'): void }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<div class="glyph">∅</div>
|
||||
<template v-if="filtered">
|
||||
<div class="title">没有符合条件的工单</div>
|
||||
<div class="hint">试试清除筛选条件,或新建一个工单。</div>
|
||||
<button class="action" @click="emit('clear')">清除筛选</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="title">暂无工单</div>
|
||||
<div class="hint">
|
||||
聊天 AI 升级问题时,工单会出现在这里——你也可以自己创建。
|
||||
</div>
|
||||
<button class="action" @click="emit('create')">新建工单</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
padding: 66px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.glyph {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: 14px;
|
||||
border: 1.5px dashed var(--o16);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--faint);
|
||||
font-size: 18px;
|
||||
}
|
||||
.title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: 17px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.action {
|
||||
padding: 8px 16px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
color: var(--text);
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.action:hover {
|
||||
background: var(--o08);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import type { TicketListFilters } from '@/types/ticket'
|
||||
import { useUsers } from '@/composables/useUsers'
|
||||
|
||||
defineProps<{ filters: TicketListFilters }>()
|
||||
|
||||
const STATUS_PILLS = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'open', label: '待处理' },
|
||||
{ value: 'awaiting', label: '等待中' },
|
||||
{ value: 'breaching', label: 'SLA 超时' },
|
||||
{ value: 'resolved', label: '已解决' },
|
||||
]
|
||||
|
||||
const AI_PILLS = [
|
||||
{ value: 'all', label: '全部 AI', color: 'var(--muted)' },
|
||||
{ value: 'investigating', label: '调查中', color: 'var(--c-info)' },
|
||||
{ value: 'awaiting', label: '等待人工', color: 'var(--c-warn)' },
|
||||
{ value: 'human', label: '人工处理', color: 'var(--c-neutral)' },
|
||||
{ value: 'resolved', label: 'AI 已解决', color: 'var(--c-positive)' },
|
||||
]
|
||||
|
||||
const { users, fetchUsers } = useUsers()
|
||||
onMounted(() => {
|
||||
fetchUsers().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-bar">
|
||||
<div class="pill-group">
|
||||
<button
|
||||
v-for="pill in STATUS_PILLS"
|
||||
:key="pill.value"
|
||||
class="pill"
|
||||
:class="{ active: filters.status === pill.value }"
|
||||
@click="filters.status = pill.value"
|
||||
>
|
||||
{{ pill.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select v-model="filters.priority" class="filter-select" aria-label="按优先级筛选">
|
||||
<option value="all">优先级 · 全部</option>
|
||||
<option value="urgent">紧急</option>
|
||||
<option value="high">高</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="low">低</option>
|
||||
</select>
|
||||
|
||||
<select v-model="filters.assignee" class="filter-select" aria-label="按负责人筛选">
|
||||
<option value="all">负责人 · 全部</option>
|
||||
<option value="unassigned">未分配 / AI</option>
|
||||
<option v-for="user in users" :key="user.id" :value="user.id">
|
||||
{{ user.full_name || user.email }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<div class="pill-group">
|
||||
<button
|
||||
v-for="pill in AI_PILLS"
|
||||
:key="pill.value"
|
||||
class="pill with-dot"
|
||||
:class="{ active: filters.ai === pill.value }"
|
||||
@click="filters.ai = pill.value"
|
||||
>
|
||||
<span class="dot" :style="{ background: pill.color }"></span>{{ pill.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search-wrap">
|
||||
<span class="search-glyph"></span>
|
||||
<input v-model="filters.search" class="search-input" placeholder="搜索工单…" />
|
||||
</div>
|
||||
|
||||
<select v-model="filters.sort" class="filter-select" aria-label="工单排序">
|
||||
<option value="updated">排序 · 更新时间</option>
|
||||
<option value="created">最新创建</option>
|
||||
<option value="priority">优先级</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pill-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
font-size: 12.5px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pill.active {
|
||||
background: var(--o10);
|
||||
color: var(--text);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.filter-select {
|
||||
padding: 8px 11px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 9px;
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
}
|
||||
.search-glyph {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border: 1.5px solid var(--faint);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.search-input {
|
||||
width: 190px;
|
||||
padding: 8px 12px 8px 30px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 9px;
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
outline: none;
|
||||
}
|
||||
.search-input:focus {
|
||||
border-color: var(--o16);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,340 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { InvestigationDetail } from '@/types/ticket'
|
||||
import HypothesisCard from './HypothesisCard.vue'
|
||||
import InvestigationNoteForm from './InvestigationNoteForm.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
investigation: InvestigationDetail
|
||||
/** Whether this user can start another run right now. */
|
||||
canReinvestigate?: boolean
|
||||
/** Why they can't, shown as a tooltip on the disabled button. */
|
||||
reinvestigateBlockedReason?: string | null
|
||||
/** Gates the deep link to settings, which needs manage_organization. */
|
||||
canEditSettings?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'reinvestigate', note: string): void }>()
|
||||
|
||||
// A guided re-run belongs here rather than only in the L2 approval banner:
|
||||
// below autonomy 2 no proposal is ever created, so that banner never renders
|
||||
// and an L1 operator had no way to correct a run short of editing the ticket
|
||||
// description (#318).
|
||||
const isNoting = ref(false)
|
||||
|
||||
function submitRerun(note: string) {
|
||||
isNoting.value = false
|
||||
emit('reinvestigate', note)
|
||||
}
|
||||
|
||||
const run = computed(() => props.investigation.run)
|
||||
const isActive = computed(() => ['pending', 'running'].includes(run.value?.status || ''))
|
||||
|
||||
const RUN_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '排队中', color: 'var(--c-neutral)' },
|
||||
running: { label: '调查中', color: 'var(--c-info)' },
|
||||
completed: { label: '已完成', color: 'var(--c-positive)' },
|
||||
failed: { label: '失败', color: 'var(--c-danger)' },
|
||||
cancelled: { label: '已取消', color: 'var(--c-neutral)' },
|
||||
budget_exceeded: { label: '部分完成 · 预算', color: 'var(--c-warn)' },
|
||||
}
|
||||
const statusMeta = computed(
|
||||
() => RUN_STATUS_META[run.value?.status || ''] || RUN_STATUS_META.pending,
|
||||
)
|
||||
|
||||
// "What the AI is doing now": the newest event, phrased for humans.
|
||||
const tickerLine = computed(() => {
|
||||
if (!isActive.value) return null
|
||||
const events = props.investigation.events
|
||||
const latest = events[events.length - 1]
|
||||
if (!latest) return '正在启动调查…'
|
||||
if (latest.event_type === 'phase') return latest.label
|
||||
return `正在查询 ${latest.connector_name || '连接器'} · ${latest.tool_name}`
|
||||
})
|
||||
|
||||
const toolCallCount = computed(
|
||||
() => props.investigation.events.filter((e) => e.event_type === 'tool_call').length,
|
||||
)
|
||||
|
||||
// Compact token total for the run header, e.g. "1.2k tokens". Only shown once
|
||||
// the run has recorded usage.
|
||||
const tokenLabel = computed(() => {
|
||||
const total = (run.value?.input_tokens || 0) + (run.value?.output_tokens || 0)
|
||||
if (!total) return ''
|
||||
return total >= 1000 ? `${(total / 1000).toFixed(1)}k tokens` : `${total} tokens`
|
||||
})
|
||||
|
||||
// How many of the run's tool calls failed. Surfaced because a verdict resting
|
||||
// on failed queries is worth nothing, however confident it reads (#318).
|
||||
const erroredCalls = computed(() => run.value?.connector_status?.tool_calls_failed || 0)
|
||||
const totalCalls = computed(() => run.value?.connector_status?.tool_calls || 0)
|
||||
const allCallsFailed = computed(() => totalCalls.value > 0 && erroredCalls.value === totalCalls.value)
|
||||
|
||||
// A run that finished with fewer MCP connectors than configured produced its
|
||||
// answer without the evidence those tools were meant to gather. So did a run
|
||||
// whose connectors all came up but whose tools the provider refused — that one
|
||||
// reports every connector loaded, so it needs its own check (#303).
|
||||
const connectorWarning = computed(() => {
|
||||
const status = run.value?.connector_status
|
||||
if (!status) return null
|
||||
const missingConnectors = status.loaded < status.configured
|
||||
const refusedTools = !!status.provider_errors?.length
|
||||
// A connector can come up, load its tools, and then error on every query.
|
||||
// That reports loaded === configured with no provider errors, so it needs
|
||||
// its own check or the run renders as healthy (#318).
|
||||
if (!missingConnectors && !refusedTools && !erroredCalls.value) return null
|
||||
return status
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="run" class="investigation-panel">
|
||||
<div class="panel-head">
|
||||
<div class="head-left">
|
||||
<span class="panel-title">AI 调查</span>
|
||||
<span class="run-chip" :style="{ color: statusMeta.color, borderColor: statusMeta.color }">
|
||||
<span v-if="isActive" class="pulse-dot" :style="{ background: statusMeta.color }"></span>
|
||||
{{ statusMeta.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="head-meta mono">
|
||||
<span>{{ investigation.hypotheses.length }} 个假设</span>
|
||||
<span>·</span>
|
||||
<span>{{ toolCallCount }}/{{ run.max_tool_calls || '—' }} 次工具调用</span>
|
||||
<span v-if="run.llm_calls">· {{ run.llm_calls }} 次 LLM 调用</span>
|
||||
<span v-if="tokenLabel">· {{ tokenLabel }}</span>
|
||||
<span v-if="run.model_name">· {{ run.model_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tickerLine" class="ticker">
|
||||
<span class="ticker-dot"></span>
|
||||
{{ tickerLine }}
|
||||
</div>
|
||||
|
||||
<div v-if="run.status === 'failed' && run.error" class="run-error">
|
||||
{{ run.error }}
|
||||
</div>
|
||||
|
||||
<div v-if="connectorWarning" class="connector-warning">
|
||||
<div v-if="connectorWarning.loaded < connectorWarning.configured">
|
||||
仅使用了 {{ connectorWarning.loaded }} / {{ connectorWarning.configured }} 个已配置连接器——结论可能缺少证据。
|
||||
</div>
|
||||
<div v-else>
|
||||
连接器已就绪,但其工具无法使用——结论可能缺少证据。
|
||||
</div>
|
||||
<ul v-if="connectorWarning.failed.length" class="connector-warning__list">
|
||||
<li v-for="f in connectorWarning.failed" :key="f.name">
|
||||
<strong>{{ f.name }}</strong>:{{ f.error }}
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-if="connectorWarning.provider_errors?.length" class="connector-warning__list">
|
||||
<li v-for="reason in connectorWarning.provider_errors" :key="reason">{{ reason }}</li>
|
||||
</ul>
|
||||
<div v-if="erroredCalls" class="connector-warning__calls">
|
||||
{{ erroredCalls }} / {{ totalCalls }} 次工具调用出错<template
|
||||
v-if="allCallsFailed"
|
||||
>——未采集到任何数据,此处结论均无证据支撑</template>。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="investigation.hypotheses.length" class="hypothesis-list">
|
||||
<HypothesisCard
|
||||
v-for="hypothesis in investigation.hypotheses"
|
||||
:key="hypothesis.id"
|
||||
:hypothesis="hypothesis"
|
||||
:events="investigation.events"
|
||||
/>
|
||||
</div>
|
||||
<p v-else-if="!isActive" class="empty-note">本次调查未生成假设。</p>
|
||||
|
||||
<div v-if="canReinvestigate" class="panel-foot">
|
||||
<button
|
||||
v-if="!isNoting"
|
||||
class="rerun-btn"
|
||||
:disabled="!!reinvestigateBlockedReason"
|
||||
:title="reinvestigateBlockedReason || '重新调查,并备注告诉 AI 这次要怎么做'"
|
||||
@click="isNoting = true"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'rotate-right']" />
|
||||
带指导重新调查…
|
||||
</button>
|
||||
<template v-else>
|
||||
<InvestigationNoteForm
|
||||
placeholder="这次希望 AI 怎么做?例如“订单号字段是 fields.order_ref,不是 order_id——请从 3 日 14:00 UTC 起搜索 app-logs-*。”"
|
||||
submit-label="重新调查"
|
||||
@cancel="isNoting = false"
|
||||
@submit="submitRerun"
|
||||
/>
|
||||
<p class="rerun-scope">
|
||||
仅对本次调查生效。若希望每次都告知 AI,请在连接器的
|
||||
<router-link v-if="canEditSettings" to="/settings/ticketing">工单设置</router-link>
|
||||
<span v-else>工单设置</span> 中配置。
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel-foot {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--o07);
|
||||
}
|
||||
.rerun-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--o12);
|
||||
color: var(--text3);
|
||||
border-radius: 10px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rerun-btn:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
.rerun-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.rerun-scope {
|
||||
margin: 8px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.connector-warning__calls {
|
||||
margin-top: 6px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
.investigation-panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 15px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.head-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.panel-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: 14.5px;
|
||||
color: var(--text);
|
||||
}
|
||||
.run-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.04em;
|
||||
border: 1px solid;
|
||||
padding: 2px 9px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.pulse-dot,
|
||||
.ticker-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
animation: inv-pulse 1.3s ease-in-out infinite;
|
||||
}
|
||||
.ticker-dot {
|
||||
background: var(--c-info);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes inv-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.head-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10.5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.ticker {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 12px;
|
||||
background: color-mix(in srgb, var(--c-info) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--c-info) 30%, transparent);
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.run-error {
|
||||
margin-top: 12px;
|
||||
padding: 9px 12px;
|
||||
background: color-mix(in srgb, var(--c-danger) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, transparent);
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--c-danger);
|
||||
}
|
||||
.connector-warning {
|
||||
margin-top: 12px;
|
||||
padding: 9px 12px;
|
||||
background: color-mix(in srgb, var(--c-warn) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--c-warn) 35%, transparent);
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--c-warn);
|
||||
}
|
||||
.connector-warning__list {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.connector-warning__list li {
|
||||
word-break: break-word;
|
||||
}
|
||||
.hypothesis-list {
|
||||
margin-top: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
}
|
||||
.empty-note {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { TicketPriority } from '@/types/ticket'
|
||||
import { priorityMeta } from './ticketMeta'
|
||||
|
||||
const props = defineProps<{ priority: TicketPriority }>()
|
||||
const meta = computed(() => priorityMeta(props.priority))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="ticket-priority" :style="{ '--chip-color': meta.color }">
|
||||
<span class="dot"></span>{{ meta.label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ticket-priority {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--chip-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 2px;
|
||||
background: var(--chip-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,381 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { RcaDocument } from '@/types/ticket'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
rca: RcaDocument
|
||||
canManage: boolean
|
||||
canNotify: boolean
|
||||
/** A save/send is in flight — both actions lock so nothing sends twice. */
|
||||
isBusy?: boolean
|
||||
}>(),
|
||||
{ isBusy: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-draft', customerSummary: string): void
|
||||
(e: 'send-customer', customerSummary: string): void
|
||||
}>()
|
||||
|
||||
const customerDraft = ref(props.rca.customer_summary || '')
|
||||
watch(
|
||||
() => props.rca.customer_summary,
|
||||
(next) => {
|
||||
customerDraft.value = next || ''
|
||||
},
|
||||
)
|
||||
|
||||
interface Section {
|
||||
id: string
|
||||
label: string
|
||||
present: boolean
|
||||
}
|
||||
|
||||
const sections = computed<Section[]>(() => [
|
||||
{ id: 'rca-summary', label: '摘要', present: !!props.rca.summary },
|
||||
{ id: 'rca-impact', label: '影响', present: !!props.rca.impact },
|
||||
{ id: 'rca-timeline', label: '时间线', present: !!props.rca.timeline?.length },
|
||||
{ id: 'rca-log', label: '调查日志', present: !!props.rca.investigation_log },
|
||||
{ id: 'rca-factors', label: '促成因素', present: !!props.rca.contributing_factors?.length },
|
||||
{ id: 'rca-conclusion', label: '结论', present: !!props.rca.conclusion },
|
||||
{ id: 'rca-remediation', label: '修复措施', present: !!props.rca.remediation },
|
||||
{ id: 'rca-prevention', label: '预防措施', present: !!props.rca.prevention },
|
||||
{ id: 'rca-customer', label: '客户摘要', present: true },
|
||||
])
|
||||
|
||||
function jumpTo(id: string) {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rca-doc">
|
||||
<div class="rca-head">
|
||||
<div>
|
||||
<div class="rca-title">根因分析</div>
|
||||
<div class="rca-byline">
|
||||
由 ReplyPier AI 生成
|
||||
<template v-if="rca.reviewed_by_name"> · 审核人 {{ rca.reviewed_by_name }}</template>
|
||||
<template v-else> · 尚未审核</template>
|
||||
<span class="version mono">v{{ rca.version }}</span>
|
||||
<span v-if="rca.is_partial" class="partial-tag">部分完成——已达预算上限</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="rca.confidence != null" class="rca-confidence mono">
|
||||
置信度 {{ rca.confidence.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rca-grid">
|
||||
<nav class="rca-toc">
|
||||
<button
|
||||
v-for="section in sections.filter((s) => s.present)"
|
||||
:key="section.id"
|
||||
class="toc-link"
|
||||
@click="jumpTo(section.id)"
|
||||
>
|
||||
{{ section.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="rca-body">
|
||||
<section v-if="rca.summary" id="rca-summary">
|
||||
<h4 class="section-label">摘要</h4>
|
||||
<p class="section-text">{{ rca.summary }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="rca.impact" id="rca-impact">
|
||||
<h4 class="section-label">影响</h4>
|
||||
<p class="section-text">{{ rca.impact }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="rca.timeline?.length" id="rca-timeline">
|
||||
<h4 class="section-label">时间线</h4>
|
||||
<div class="timeline">
|
||||
<div v-for="(entry, i) in rca.timeline" :key="i" class="timeline-row">
|
||||
<span class="timeline-time mono">{{ entry.time }}</span>
|
||||
<span class="timeline-event">{{ entry.event }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="rca.investigation_log" id="rca-log">
|
||||
<h4 class="section-label">调查日志</h4>
|
||||
<p class="section-text">{{ rca.investigation_log }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="rca.contributing_factors?.length" id="rca-factors">
|
||||
<h4 class="section-label">促成因素</h4>
|
||||
<ul class="factor-list">
|
||||
<li v-for="(factor, i) in rca.contributing_factors" :key="i">{{ factor }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="rca.conclusion" id="rca-conclusion">
|
||||
<h4 class="section-label">结论</h4>
|
||||
<p class="section-text conclusion">{{ rca.conclusion }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="rca.remediation" id="rca-remediation">
|
||||
<h4 class="section-label">修复措施</h4>
|
||||
<p class="section-text">{{ rca.remediation }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="rca.prevention" id="rca-prevention">
|
||||
<h4 class="section-label">预防措施</h4>
|
||||
<p class="section-text">{{ rca.prevention }}</p>
|
||||
</section>
|
||||
|
||||
<section id="rca-customer">
|
||||
<h4 class="section-label">客户摘要</h4>
|
||||
<div class="customer-card">
|
||||
<textarea
|
||||
v-if="canManage"
|
||||
v-model="customerDraft"
|
||||
class="customer-editor"
|
||||
placeholder="给客户的通俗摘要…"
|
||||
></textarea>
|
||||
<p v-else class="section-text">{{ rca.customer_summary || '—' }}</p>
|
||||
<div v-if="canManage" class="customer-actions">
|
||||
<span v-if="!canNotify" class="no-channel-note">
|
||||
没有客户联系渠道——请关联会话或添加客户邮箱后再发送。
|
||||
</span>
|
||||
<button
|
||||
class="draft-btn"
|
||||
:disabled="props.isBusy"
|
||||
@click="emit('save-draft', customerDraft)"
|
||||
>
|
||||
保存草稿
|
||||
</button>
|
||||
<button
|
||||
class="send-btn"
|
||||
:disabled="props.isBusy || !canNotify || !customerDraft.trim()"
|
||||
@click="emit('send-customer', customerDraft)"
|
||||
>
|
||||
{{ props.isBusy ? '发送中…' : '发送给客户' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rca-doc {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 15px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.rca-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.rca-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
}
|
||||
.rca-byline {
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
color: var(--faint);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.version {
|
||||
color: var(--muted2);
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o08);
|
||||
padding: 1px 6px;
|
||||
border-radius: 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.partial-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
color: var(--c-warn);
|
||||
background: color-mix(in srgb, var(--c-warn) 12%, transparent);
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.rca-confidence {
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.rca-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 148px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
.rca-toc {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.toc-link {
|
||||
text-align: left;
|
||||
padding: 5px 9px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toc-link:hover {
|
||||
background: var(--o05);
|
||||
color: var(--text);
|
||||
}
|
||||
.rca-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 17px;
|
||||
min-width: 0;
|
||||
}
|
||||
.section-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
margin: 0 0 7px;
|
||||
}
|
||||
.section-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text3);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.section-text.conclusion {
|
||||
color: var(--text);
|
||||
}
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.timeline-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.timeline-time {
|
||||
color: var(--muted2);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
min-width: 92px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.timeline-event {
|
||||
color: var(--text3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.factor-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text3);
|
||||
}
|
||||
.customer-card {
|
||||
border: 1.5px solid var(--c-teal);
|
||||
background: var(--teal-bg-10, color-mix(in srgb, var(--c-teal) 8%, transparent));
|
||||
border-radius: 12px;
|
||||
padding: 13px 14px;
|
||||
}
|
||||
.customer-editor {
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 9px;
|
||||
padding: 10px 12px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
outline: none;
|
||||
}
|
||||
.customer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 9px;
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.no-channel-note {
|
||||
margin-right: auto;
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
}
|
||||
.draft-btn {
|
||||
padding: 7px 13px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--text);
|
||||
border-radius: 9px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.send-btn {
|
||||
padding: 7px 15px;
|
||||
background: var(--c-teal);
|
||||
color: var(--on-light, #04140d);
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.send-btn:disabled,
|
||||
.draft-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.rca-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.rca-toc {
|
||||
position: static;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,466 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { Ticket } from '@/types/ticket'
|
||||
import { formatSlaCountdown, ticketInitials } from './ticketMeta'
|
||||
import { useUsers } from '@/composables/useUsers'
|
||||
|
||||
const props = defineProps<{
|
||||
ticket: Ticket
|
||||
linkedSessionIds: string[]
|
||||
canManage: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'assign', userId: string | null): void
|
||||
(e: 'set-customer', email: string, name?: string): void
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const { users, fetchUsers } = useUsers()
|
||||
const isPickingAssignee = ref(false)
|
||||
|
||||
const isEditingCustomer = ref(false)
|
||||
const customerEmailDraft = ref('')
|
||||
const customerNameDraft = ref('')
|
||||
|
||||
function startCustomerEdit() {
|
||||
customerEmailDraft.value = props.ticket.customer?.email || ''
|
||||
customerNameDraft.value = props.ticket.customer?.full_name || ''
|
||||
isEditingCustomer.value = true
|
||||
}
|
||||
|
||||
function saveCustomer() {
|
||||
if (!customerEmailDraft.value.trim()) return
|
||||
emit('set-customer', customerEmailDraft.value, customerNameDraft.value)
|
||||
isEditingCustomer.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.canManage) fetchUsers().catch(() => {})
|
||||
})
|
||||
|
||||
const sla = computed(() => formatSlaCountdown(props.ticket.sla_due_at, props.ticket.resolved_at))
|
||||
const firstResponseMet = computed(() => !!props.ticket.first_response_at)
|
||||
|
||||
const assigneeName = computed(
|
||||
() => props.ticket.assignee?.full_name || props.ticket.assignee_name || null,
|
||||
)
|
||||
|
||||
// CSAT is asked on close, through the linked conversation's rating prompt, so
|
||||
// a ticket with no conversation is never asked at all. A reopened-then-closed
|
||||
// ticket is asked again, hence the timestamp comparison rather than a plain
|
||||
// "has a score" check — the newest ask is the one being waited on.
|
||||
const csat = computed(() => {
|
||||
const { csat_score: score, csat_requested_at: asked, csat_responded_at: answered } = props.ticket
|
||||
const awaitingAnswer = !!asked && (!answered || new Date(asked) > new Date(answered))
|
||||
|
||||
if (score != null && !awaitingAnswer) {
|
||||
return {
|
||||
state: 'scored' as const,
|
||||
score,
|
||||
label: `${score}/5`,
|
||||
color: score >= 4 ? 'var(--c-positive)' : score >= 3 ? 'var(--c-warn)' : 'var(--c-danger)',
|
||||
}
|
||||
}
|
||||
if (awaitingAnswer) {
|
||||
return { state: 'pending' as const, score: 0, label: '待评价', color: 'var(--muted2)' }
|
||||
}
|
||||
return { state: 'not-requested' as const, score: 0, label: '未邀请评价', color: 'var(--faint)' }
|
||||
})
|
||||
|
||||
function openConversation() {
|
||||
if (props.linkedSessionIds.length) {
|
||||
router.push({ path: '/conversations', query: { session: props.linkedSessionIds[0] } })
|
||||
}
|
||||
}
|
||||
|
||||
function pickAssignee(userId: string | null) {
|
||||
isPickingAssignee.value = false
|
||||
emit('assign', userId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="side-panel">
|
||||
<div class="panel-card">
|
||||
<div class="card-label">负责人</div>
|
||||
<div class="assignee-row">
|
||||
<span class="avatar" :class="{ ai: !assigneeName }">
|
||||
{{ ticketInitials(assigneeName) }}
|
||||
</span>
|
||||
<div class="assignee-info">
|
||||
<div class="assignee-name">{{ assigneeName || 'ReplyPier AI' }}</div>
|
||||
<div class="assignee-sub">{{ assigneeName ? '人工坐席' : '未分配——由 AI 处理' }}</div>
|
||||
</div>
|
||||
<button v-if="canManage" class="change-btn" @click="isPickingAssignee = !isPickingAssignee">
|
||||
更改
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isPickingAssignee" class="assignee-picker">
|
||||
<button class="picker-option" @click="pickAssignee(null)">ReplyPier AI(取消分配)</button>
|
||||
<button
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="picker-option"
|
||||
@click="pickAssignee(user.id)"
|
||||
>
|
||||
{{ user.full_name || user.email }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="card-label-row">
|
||||
<div class="card-label">客户</div>
|
||||
<button
|
||||
v-if="canManage && !isEditingCustomer"
|
||||
class="change-btn"
|
||||
@click="startCustomerEdit"
|
||||
>
|
||||
{{ ticket.customer ? '编辑' : '添加' }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="isEditingCustomer">
|
||||
<input
|
||||
v-model="customerEmailDraft"
|
||||
type="email"
|
||||
class="customer-input"
|
||||
placeholder="customer@company.com"
|
||||
maxlength="320"
|
||||
/>
|
||||
<input
|
||||
v-model="customerNameDraft"
|
||||
class="customer-input"
|
||||
placeholder="姓名(选填)"
|
||||
maxlength="200"
|
||||
/>
|
||||
<div class="customer-edit-actions">
|
||||
<button class="customer-cancel" @click="isEditingCustomer = false">取消</button>
|
||||
<button
|
||||
class="customer-save"
|
||||
:disabled="!customerEmailDraft.trim()"
|
||||
@click="saveCustomer"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="ticket.customer" class="customer-row">
|
||||
<span class="customer-avatar">
|
||||
{{ (ticket.customer.full_name || ticket.customer.email || '?')[0]?.toUpperCase() }}
|
||||
</span>
|
||||
<div>
|
||||
<div class="assignee-name">{{ ticket.customer.full_name || '客户' }}</div>
|
||||
<div class="assignee-sub">{{ ticket.customer.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-customer">
|
||||
未关联客户——此工单无法发送通知。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="linkedSessionIds.length" class="panel-card">
|
||||
<div class="card-label">关联会话</div>
|
||||
<button class="open-conversation" @click="openConversation">打开会话 →</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="card-label">SLA</div>
|
||||
<div class="sla-row">
|
||||
<span class="sla-label">首次响应</span>
|
||||
<span class="sla-value" :style="{ color: firstResponseMet ? 'var(--c-positive)' : 'var(--muted2)' }">
|
||||
<template v-if="firstResponseMet">
|
||||
<font-awesome-icon :icon="['fas', 'check']" /> 已达成
|
||||
</template>
|
||||
<template v-else>等待中</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="sla-row">
|
||||
<span class="sla-label">解决时间</span>
|
||||
<span class="sla-value mono" :style="{ color: sla.color }">{{ sla.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="card-label">CSAT</div>
|
||||
<div class="csat-row">
|
||||
<span v-if="csat.state === 'scored'" class="csat-stars" :style="{ color: csat.color }">
|
||||
<font-awesome-icon
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
:icon="[star <= csat.score ? 'fas' : 'far', 'star']"
|
||||
/>
|
||||
</span>
|
||||
<span class="sla-value" :style="{ color: csat.color }">{{ csat.label }}</span>
|
||||
</div>
|
||||
<p class="csat-note">
|
||||
<template v-if="csat.state === 'scored'">客户已在关联会话中评分。</template>
|
||||
<template v-else-if="csat.state === 'pending'">关闭时已邀请评价——等待客户评分。</template>
|
||||
<template v-else-if="linkedSessionIds.length">工单关闭时会自动邀请评价。</template>
|
||||
<template v-else>没有可评价的关联会话,因此此工单不收集 CSAT。</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="ticket.intent || ticket.ai_summary" class="panel-card">
|
||||
<div class="card-label">AI 分诊</div>
|
||||
<div v-if="ticket.intent" class="triage-row">
|
||||
<span class="sla-label">意图</span>
|
||||
<span class="sla-value mono">{{ ticket.intent }}</span>
|
||||
</div>
|
||||
<div v-if="ticket.triage_confidence != null" class="triage-row">
|
||||
<span class="sla-label">置信度</span>
|
||||
<span class="sla-value mono">{{ ticket.triage_confidence.toFixed(2) }}</span>
|
||||
</div>
|
||||
<p v-if="ticket.ai_summary" class="triage-summary">{{ ticket.ai_summary }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="ticket.external_ref_id" class="panel-card">
|
||||
<div class="card-label">外部引用</div>
|
||||
<a
|
||||
:href="ticket.external_ref_url || '#'"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="external-ref"
|
||||
>
|
||||
{{ ticket.external_ref_type }} · {{ ticket.external_ref_id }}
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.side-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.panel-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 13px;
|
||||
padding: 15px;
|
||||
}
|
||||
.card-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
.assignee-row,
|
||||
.customer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--c-coral);
|
||||
color: var(--on-accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar.ai {
|
||||
border-radius: 9px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
.no-customer {
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.card-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.customer-input {
|
||||
width: 100%;
|
||||
padding: 8px 11px;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 9px;
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
outline: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.customer-edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.customer-cancel {
|
||||
padding: 6px 12px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.customer-save {
|
||||
padding: 6px 14px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.customer-save:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.customer-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: var(--grad-purple-teal);
|
||||
color: var(--on-accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-family: var(--font-display);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.assignee-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.assignee-name {
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text);
|
||||
}
|
||||
.assignee-sub {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.change-btn {
|
||||
font-size: 11.5px;
|
||||
color: var(--accent-ink);
|
||||
background: var(--accent-bg-08);
|
||||
border: none;
|
||||
padding: 4px 9px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.assignee-picker {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.picker-option {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text3);
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.picker-option:hover {
|
||||
background: var(--o05);
|
||||
color: var(--text);
|
||||
}
|
||||
.open-conversation {
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--text);
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.open-conversation:hover {
|
||||
background: var(--o08);
|
||||
}
|
||||
.sla-row,
|
||||
.triage-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
.sla-row:last-child,
|
||||
.triage-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.sla-label {
|
||||
font-size: 12.5px;
|
||||
color: var(--text3);
|
||||
}
|
||||
.sla-value {
|
||||
font-size: 11.5px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.csat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.csat-stars {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.csat-note {
|
||||
margin: 9px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--faint);
|
||||
}
|
||||
.triage-summary {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text3);
|
||||
}
|
||||
.external-ref {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--c-teal);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { TicketStatus } from '@/types/ticket'
|
||||
import { statusMeta } from './ticketMeta'
|
||||
|
||||
const props = defineProps<{ status: TicketStatus }>()
|
||||
const meta = computed(() => statusMeta(props.status))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="ticket-status-badge" :style="{ '--chip-color': meta.color }">
|
||||
{{ meta.label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ticket-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11.5px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--chip-color);
|
||||
background: color-mix(in srgb, var(--chip-color) 14%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Shared display metadata for ticket statuses / priorities / AI states.
|
||||
Colors come from the design tokens so both themes work.
|
||||
*/
|
||||
|
||||
import type { HypothesisStatus, TicketAiState, TicketPriority, TicketStatus } from '@/types/ticket'
|
||||
|
||||
export interface ChipMeta {
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export const STATUS_META: Record<TicketStatus, ChipMeta> = {
|
||||
open: { label: '待处理', color: 'var(--c-info)' },
|
||||
triaging: { label: '待处理 · AI 分诊', color: 'var(--c-info)' },
|
||||
investigating: { label: '待处理 · AI 处理中', color: 'var(--c-info)' },
|
||||
awaiting_approval: { label: '待审批', color: 'var(--c-warn)' },
|
||||
in_progress: { label: '处理中', color: 'var(--c-teal)' },
|
||||
resolved_pending_confirmation: { label: '已解决 · 待确认', color: 'var(--c-positive)' },
|
||||
resolved: { label: '已解决', color: 'var(--c-positive)' },
|
||||
closed: { label: '已关闭', color: 'var(--c-neutral)' },
|
||||
reopened: { label: '重新打开', color: 'var(--c-coral)' },
|
||||
}
|
||||
|
||||
export const PRIORITY_META: Record<TicketPriority, ChipMeta> = {
|
||||
urgent: { label: '紧急', color: 'var(--c-danger)' },
|
||||
high: { label: '高', color: 'var(--c-coral)' },
|
||||
medium: { label: '中', color: 'var(--c-warn)' },
|
||||
low: { label: '低', color: 'var(--c-neutral)' },
|
||||
}
|
||||
|
||||
export const AI_STATE_META: Record<TicketAiState, ChipMeta & { pulse: boolean }> = {
|
||||
investigating: { label: 'AI 调查中', color: 'var(--c-info)', pulse: true },
|
||||
awaiting: { label: '等待人工', color: 'var(--c-warn)', pulse: false },
|
||||
human: { label: '人工处理', color: 'var(--c-neutral)', pulse: false },
|
||||
resolved: { label: 'AI 已解决', color: 'var(--c-positive)', pulse: false },
|
||||
}
|
||||
|
||||
// Human-selectable statuses for the detail dropdown, in lifecycle order.
|
||||
// Triaging/investigating are AI-driven and not directly selectable.
|
||||
export const SELECTABLE_STATUSES: TicketStatus[] = [
|
||||
'open',
|
||||
'in_progress',
|
||||
'awaiting_approval',
|
||||
'resolved_pending_confirmation',
|
||||
'resolved',
|
||||
'closed',
|
||||
'reopened',
|
||||
]
|
||||
|
||||
export const PRIORITIES: TicketPriority[] = ['urgent', 'high', 'medium', 'low']
|
||||
|
||||
export function statusMeta(status: TicketStatus): ChipMeta {
|
||||
return STATUS_META[status] || { label: status, color: 'var(--c-neutral)' }
|
||||
}
|
||||
|
||||
export function priorityMeta(priority: TicketPriority): ChipMeta {
|
||||
return PRIORITY_META[priority] || { label: priority, color: 'var(--c-neutral)' }
|
||||
}
|
||||
|
||||
export function aiStateMeta(state?: TicketAiState | null) {
|
||||
return (state && AI_STATE_META[state]) || AI_STATE_META.human
|
||||
}
|
||||
|
||||
export const HYPOTHESIS_STATUS_META: Record<HypothesisStatus, ChipMeta> = {
|
||||
pending: { label: '待验证', color: 'var(--c-neutral)' },
|
||||
testing: { label: '验证中', color: 'var(--c-info)' },
|
||||
validated: { label: '已验证', color: 'var(--c-positive)' },
|
||||
invalidated: { label: '已推翻', color: 'var(--c-danger)' },
|
||||
inconclusive: { label: '无定论', color: 'var(--c-warn)' },
|
||||
}
|
||||
|
||||
export function hypothesisMeta(status: HypothesisStatus): ChipMeta {
|
||||
return HYPOTHESIS_STATUS_META[status] || { label: status, color: 'var(--c-neutral)' }
|
||||
}
|
||||
|
||||
export function ticketInitials(name?: string | null): string {
|
||||
if (!name) return 'AI'
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function formatSlaCountdown(slaDueAt?: string | null, resolvedAt?: string | null): ChipMeta {
|
||||
if (resolvedAt || !slaDueAt) return { label: '—', color: 'var(--faint)' }
|
||||
const remainingMs = new Date(slaDueAt).getTime() - Date.now()
|
||||
if (remainingMs <= 0) return { label: '已超时', color: 'var(--c-danger)' }
|
||||
const minutes = Math.floor(remainingMs / 60000)
|
||||
const label =
|
||||
minutes >= 60 * 24
|
||||
? `${Math.floor(minutes / (60 * 24))}天 ${Math.floor((minutes % (60 * 24)) / 60)}小时`
|
||||
: minutes >= 60
|
||||
? `${Math.floor(minutes / 60)}小时 ${minutes % 60}分`
|
||||
: `${minutes}分钟`
|
||||
const color =
|
||||
minutes < 10 ? 'var(--c-danger)' : minutes < 30 ? 'var(--c-warn)' : 'var(--muted2)'
|
||||
return { label, color }
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type { Node } from '@vue-flow/core'
|
||||
|
||||
const props = defineProps<{
|
||||
node: Node
|
||||
workflowId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save', config: any): void
|
||||
(e: 'cancel'): void
|
||||
(e: 'delete', nodeId: string): void
|
||||
}>()
|
||||
|
||||
// Form state
|
||||
const formData = ref({
|
||||
label: '',
|
||||
description: '',
|
||||
config: {} as Record<string, any>
|
||||
})
|
||||
|
||||
// Initialize form data
|
||||
onMounted(() => {
|
||||
formData.value = {
|
||||
label: props.node.data.label || '',
|
||||
description: props.node.data.description || '',
|
||||
config: { ...(props.node.data.config || {}) }
|
||||
}
|
||||
})
|
||||
|
||||
// Field configuration type
|
||||
interface FieldConfig {
|
||||
key: string
|
||||
label: string
|
||||
type: string
|
||||
required?: boolean
|
||||
placeholder?: string
|
||||
options?: string[]
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
default?: any
|
||||
arrayType?: string
|
||||
}
|
||||
|
||||
// Node type configurations
|
||||
const nodeConfigs: Record<string, { fields: FieldConfig[] }> = {
|
||||
message: {
|
||||
fields: [
|
||||
{ key: 'message', label: '消息内容', type: 'textarea', required: true }
|
||||
]
|
||||
},
|
||||
llm: {
|
||||
fields: [
|
||||
{ key: 'model', label: 'AI 模型', type: 'select', options: ['gpt-4', 'gpt-3.5-turbo', 'claude-3'], required: true },
|
||||
{ key: 'systemPrompt', label: '系统提示词', type: 'textarea', required: true },
|
||||
{ key: 'temperature', label: '温度', type: 'number', min: 0, max: 2, step: 0.1, default: 0.7 },
|
||||
{ key: 'maxTokens', label: '最大 Token 数', type: 'number', min: 1, max: 4000, default: 1000 }
|
||||
]
|
||||
},
|
||||
condition: {
|
||||
fields: [
|
||||
{ key: 'condition', label: '条件表达式', type: 'textarea', required: true },
|
||||
{ key: 'conditionType', label: '条件类型', type: 'select', options: ['javascript', 'simple'], required: true }
|
||||
]
|
||||
},
|
||||
form: {
|
||||
fields: [
|
||||
{ key: 'fields', label: '表单字段', type: 'array', arrayType: 'object', required: true }
|
||||
]
|
||||
},
|
||||
action: {
|
||||
fields: [
|
||||
{ key: 'actionType', label: '动作类型', type: 'select', options: ['webhook', 'email', 'api'], required: true },
|
||||
{ key: 'url', label: 'URL', type: 'text', required: true },
|
||||
{ key: 'method', label: 'HTTP 方法', type: 'select', options: ['GET', 'POST', 'PUT', 'DELETE'], default: 'POST' },
|
||||
{ key: 'headers', label: '请求头', type: 'object' },
|
||||
{ key: 'body', label: '请求体', type: 'textarea' }
|
||||
]
|
||||
},
|
||||
humanTransfer: {
|
||||
fields: [
|
||||
{ key: 'department', label: '部门', type: 'select', options: ['general', 'sales', 'support', 'technical'], required: true },
|
||||
{ key: 'message', label: '转接消息', type: 'textarea' },
|
||||
{ key: 'priority', label: '优先级', type: 'select', options: ['low', 'medium', 'high'], default: 'medium' }
|
||||
]
|
||||
},
|
||||
wait: {
|
||||
fields: [
|
||||
{ key: 'duration', label: '时长', type: 'number', min: 1, required: true },
|
||||
{ key: 'unit', label: '时间单位', type: 'select', options: ['seconds', 'minutes', 'hours', 'days'], default: 'seconds' },
|
||||
{ key: 'waitType', label: '等待类型', type: 'select', options: ['fixed', 'user_input'], default: 'fixed' }
|
||||
]
|
||||
},
|
||||
end: {
|
||||
fields: [
|
||||
{ key: 'endType', label: '结束类型', type: 'select', options: ['success', 'failure', 'timeout'], default: 'success' },
|
||||
{ key: 'finalMessage', label: '最终消息', type: 'textarea' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Get configuration for current node type
|
||||
const currentConfig = computed(() => {
|
||||
return nodeConfigs[props.node.type as keyof typeof nodeConfigs] || { fields: [] }
|
||||
})
|
||||
|
||||
// Handle form submission
|
||||
const handleSave = () => {
|
||||
emit('save', formData.value)
|
||||
}
|
||||
|
||||
// Handle cancel
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = () => {
|
||||
if (confirm('确定要删除此节点吗?')) {
|
||||
emit('delete', props.node.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle array field changes
|
||||
const addArrayItem = (fieldKey: string) => {
|
||||
if (!formData.value.config[fieldKey]) {
|
||||
formData.value.config[fieldKey] = []
|
||||
}
|
||||
formData.value.config[fieldKey].push({})
|
||||
}
|
||||
|
||||
const removeArrayItem = (fieldKey: string, index: number) => {
|
||||
if (formData.value.config[fieldKey]) {
|
||||
formData.value.config[fieldKey].splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Get node type display name
|
||||
const getNodeTypeName = (type: string | undefined) => {
|
||||
if (!type) return '未知'
|
||||
const names = {
|
||||
message: '消息',
|
||||
llm: 'LLM',
|
||||
condition: '条件',
|
||||
form: '表单',
|
||||
action: '动作',
|
||||
humanTransfer: '转人工',
|
||||
wait: '等待',
|
||||
end: '结束'
|
||||
}
|
||||
return names[type as keyof typeof names] || type
|
||||
}
|
||||
|
||||
// Handle object field changes
|
||||
const handleObjectChange = (fieldKey: string, value: string) => {
|
||||
try {
|
||||
formData.value.config[fieldKey] = JSON.parse(value)
|
||||
} catch (e) {
|
||||
// Keep as string if not valid JSON
|
||||
formData.value.config[fieldKey] = value
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="node-config-overlay" @click="handleCancel">
|
||||
<div class="node-config-panel" @click.stop>
|
||||
<div class="panel-header">
|
||||
<div class="header-left">
|
||||
<h3>配置{{ getNodeTypeName(node.type) }}节点</h3>
|
||||
<span class="node-id">ID:{{ node.id }}</span>
|
||||
</div>
|
||||
<button class="close-button" @click="handleCancel">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<form @submit.prevent="handleSave">
|
||||
<!-- Basic Information -->
|
||||
<div class="form-section">
|
||||
<h4>基本信息</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="node-label">节点名称 *</label>
|
||||
<input
|
||||
id="node-label"
|
||||
v-model="formData.label"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="输入节点名称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="node-description">描述</label>
|
||||
<textarea
|
||||
id="node-description"
|
||||
v-model="formData.description"
|
||||
class="form-textarea"
|
||||
placeholder="输入节点描述(可选)"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node-specific Configuration -->
|
||||
<div v-if="currentConfig.fields.length > 0" class="form-section">
|
||||
<h4>{{ getNodeTypeName(node.type) }}配置</h4>
|
||||
|
||||
<div
|
||||
v-for="field in currentConfig.fields"
|
||||
:key="field.key"
|
||||
class="form-group"
|
||||
>
|
||||
<label :for="`config-${field.key}`">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
|
||||
<!-- Text Input -->
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
:id="`config-${field.key}`"
|
||||
v-model="formData.config[field.key]"
|
||||
type="text"
|
||||
class="form-input"
|
||||
:placeholder="field.placeholder || `输入${field.label?.toLowerCase() || '值'}`"
|
||||
:required="field.required"
|
||||
/>
|
||||
|
||||
<!-- Number Input -->
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
:id="`config-${field.key}`"
|
||||
v-model.number="formData.config[field.key]"
|
||||
type="number"
|
||||
class="form-input"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
:step="field.step"
|
||||
:placeholder="field.default?.toString() || ''"
|
||||
:required="field.required"
|
||||
/>
|
||||
|
||||
<!-- Textarea -->
|
||||
<textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:id="`config-${field.key}`"
|
||||
v-model="formData.config[field.key]"
|
||||
class="form-textarea"
|
||||
:placeholder="field.placeholder || `输入${field.label?.toLowerCase() || '值'}`"
|
||||
rows="4"
|
||||
:required="field.required"
|
||||
></textarea>
|
||||
|
||||
<!-- Select -->
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
:id="`config-${field.key}`"
|
||||
v-model="formData.config[field.key]"
|
||||
class="form-select"
|
||||
:required="field.required"
|
||||
>
|
||||
<option value="">选择{{ field.label.toLowerCase() }}</option>
|
||||
<option
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Checkbox -->
|
||||
<label
|
||||
v-else-if="field.type === 'checkbox'"
|
||||
class="checkbox-label"
|
||||
>
|
||||
<input
|
||||
:id="`config-${field.key}`"
|
||||
v-model="formData.config[field.key]"
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
<span class="checkbox-text">{{ field.label }}</span>
|
||||
</label>
|
||||
|
||||
<!-- Object (JSON) -->
|
||||
<textarea
|
||||
v-else-if="field.type === 'object'"
|
||||
:id="`config-${field.key}`"
|
||||
:value="JSON.stringify(formData.config[field.key] || {}, null, 2)"
|
||||
@input="handleObjectChange(field.key, ($event.target as HTMLTextAreaElement).value)"
|
||||
class="form-textarea"
|
||||
placeholder="输入 JSON 对象"
|
||||
rows="4"
|
||||
></textarea>
|
||||
|
||||
<!-- Array -->
|
||||
<div v-else-if="field.type === 'array'" class="array-field">
|
||||
<div
|
||||
v-for="(item, index) in formData.config[field.key] || []"
|
||||
:key="index"
|
||||
class="array-item"
|
||||
>
|
||||
<input
|
||||
v-if="field.arrayType === 'string'"
|
||||
v-model="formData.config[field.key][index]"
|
||||
type="text"
|
||||
class="form-input"
|
||||
:placeholder="`${field.label} ${index + 1}`"
|
||||
/>
|
||||
<div v-else-if="field.arrayType === 'object'" class="object-item">
|
||||
<input
|
||||
v-model="formData.config[field.key][index].name"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="字段名"
|
||||
/>
|
||||
<select
|
||||
v-model="formData.config[field.key][index].type"
|
||||
class="form-select"
|
||||
>
|
||||
<option value="text">文本</option>
|
||||
<option value="email">邮箱</option>
|
||||
<option value="number">数字</option>
|
||||
<option value="select">下拉选择</option>
|
||||
<option value="checkbox">复选框</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-item-btn"
|
||||
@click="removeArrayItem(field.key, index)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="add-item-btn"
|
||||
@click="addArrayItem(field.key)"
|
||||
>
|
||||
添加{{ field.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
<button type="button" class="btn btn-danger" @click="handleDelete">
|
||||
删除节点
|
||||
</button>
|
||||
<div class="footer-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="handleCancel">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" @click="handleSave">
|
||||
保存修改
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.node-config-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.node-config-panel {
|
||||
background: var(--background-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--background-soft);
|
||||
}
|
||||
|
||||
.header-left h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.node-id {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: var(--background-muted);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.close-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.form-section h4 {
|
||||
margin: 0 0 var(--space-md) 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: var(--text-sm);
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
cursor: pointer;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checkbox-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.array-field {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-sm);
|
||||
background: var(--background-soft);
|
||||
}
|
||||
|
||||
.array-item {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.object-item {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.remove-item-btn {
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.add-item-btn {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.add-item-btn:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-lg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--background-soft);
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--background-muted);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--background-alt);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #DC2626;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.node-config-panel {
|
||||
width: 95%;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,145 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import type { Node } from '@vue-flow/core'
|
||||
|
||||
defineProps<{
|
||||
data: Node['data']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="action-node">
|
||||
<Handle
|
||||
id="input"
|
||||
type="target"
|
||||
:position="Position.Left"
|
||||
class="node-handle"
|
||||
/>
|
||||
|
||||
<div class="node-content">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">⚡</div>
|
||||
<div class="node-title">{{ data.label || '动作' }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.description" class="node-description">
|
||||
{{ data.description }}
|
||||
</div>
|
||||
|
||||
<div v-if="data.config?.actionType" class="node-preview">
|
||||
<div class="preview-label">动作类型:</div>
|
||||
<div class="preview-content">{{ data.config.actionType }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.config?.url" class="node-preview">
|
||||
<div class="preview-label">URL:</div>
|
||||
<div class="preview-content">
|
||||
{{ data.config.url.substring(0, 80) }}{{ data.config.url.length > 80 ? '...' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
id="output"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="node-handle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action-node {
|
||||
position: relative;
|
||||
background: var(--background-color);
|
||||
border: 2px solid #EF4444;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-node:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.node-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-preview {
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-handle {
|
||||
background: #EF4444;
|
||||
border: 2px solid var(--background-color);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.node-handle:hover {
|
||||
background: #DC2626;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface ActionNodeData {
|
||||
action_type: string
|
||||
action_url: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: ActionNodeData
|
||||
validationErrors: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:model-value', value: ActionNodeData): void
|
||||
(e: 'validate-field', field: string): void
|
||||
}>()
|
||||
|
||||
const formData = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: ActionNodeData) => emit('update:model-value', value)
|
||||
})
|
||||
|
||||
// Update form data
|
||||
const updateFormData = (field: keyof ActionNodeData, value: any) => {
|
||||
formData.value = {
|
||||
...formData.value,
|
||||
[field]: value
|
||||
}
|
||||
|
||||
emit('validate-field', field)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="action-node-config">
|
||||
<div class="form-group">
|
||||
<label for="action-type">动作类型 *</label>
|
||||
<select
|
||||
id="action-type"
|
||||
:value="formData.action_type"
|
||||
@change="updateFormData('action_type', ($event.target as HTMLSelectElement).value)"
|
||||
@blur="$emit('validate-field', 'action_type')"
|
||||
class="form-select"
|
||||
:class="{ 'error': validationErrors.action_type }"
|
||||
required
|
||||
>
|
||||
<option value="">选择动作类型</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="email">发送邮件</option>
|
||||
<option value="api">API 调用</option>
|
||||
</select>
|
||||
<div v-if="validationErrors.action_type" class="error-message">
|
||||
{{ validationErrors.action_type }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="action-url">URL *</label>
|
||||
<input
|
||||
id="action-url"
|
||||
:value="formData.action_url"
|
||||
@input="updateFormData('action_url', ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('validate-field', 'action_url')"
|
||||
type="url"
|
||||
class="form-input"
|
||||
:class="{ 'error': validationErrors.action_url }"
|
||||
placeholder="https://example.com/webhook"
|
||||
required
|
||||
/>
|
||||
<div v-if="validationErrors.action_url" class="error-message">
|
||||
{{ validationErrors.action_url }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action-node-config {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
|
||||
}
|
||||
|
||||
.form-input.error,
|
||||
.form-select.error {
|
||||
border-color: var(--error-color);
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.form-input.error:focus,
|
||||
.form-select.error:focus {
|
||||
border-color: var(--error-color);
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.error-message::before {
|
||||
content: "⚠";
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import type { Node } from '@vue-flow/core'
|
||||
|
||||
defineProps<{
|
||||
data: Node['data']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="condition-node">
|
||||
<Handle
|
||||
id="input"
|
||||
type="target"
|
||||
:position="Position.Left"
|
||||
class="node-handle"
|
||||
/>
|
||||
|
||||
<div class="node-content">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">🔀</div>
|
||||
<div class="node-title">{{ data.label || '条件' }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.description" class="node-description">
|
||||
{{ data.description }}
|
||||
</div>
|
||||
|
||||
<div v-if="data.config?.condition" class="node-preview">
|
||||
<div class="preview-label">条件:</div>
|
||||
<div class="preview-content">
|
||||
{{ data.config.condition.substring(0, 80) }}{{ data.config.condition.length > 80 ? '...' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
id="true"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
:style="{ top: '30%' }"
|
||||
class="node-handle true-handle"
|
||||
/>
|
||||
<div class="handle-label true-label">是</div>
|
||||
|
||||
<Handle
|
||||
id="false"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
:style="{ top: '70%' }"
|
||||
class="node-handle false-handle"
|
||||
/>
|
||||
<div class="handle-label false-label">否</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.condition-node {
|
||||
position: relative;
|
||||
background: var(--background-color);
|
||||
border: 2px solid #F59E0B;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.condition-node:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.node-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-preview {
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-handle {
|
||||
background: #F59E0B;
|
||||
border: 2px solid var(--background-color);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.node-handle.true-handle {
|
||||
background: #10B981;
|
||||
}
|
||||
|
||||
.node-handle.false-handle {
|
||||
background: #EF4444;
|
||||
}
|
||||
|
||||
.node-handle:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.handle-label {
|
||||
position: absolute;
|
||||
right: -35px;
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.true-label {
|
||||
top: 25%;
|
||||
color: #10B981;
|
||||
}
|
||||
|
||||
.false-label {
|
||||
top: 65%;
|
||||
color: #EF4444;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,592 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
interface ConditionRule {
|
||||
id: string
|
||||
variable: string
|
||||
operator: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface ConditionGroup {
|
||||
id: string
|
||||
rules: ConditionRule[]
|
||||
logicalOperator: 'AND' | 'OR'
|
||||
}
|
||||
|
||||
interface ConditionNodeData {
|
||||
condition_expression: string
|
||||
condition_groups?: ConditionGroup[]
|
||||
}
|
||||
|
||||
interface Variable {
|
||||
nodeId: string
|
||||
nodeName: string
|
||||
fieldName: string
|
||||
fieldType: string
|
||||
fieldLabel: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: ConditionNodeData
|
||||
validationErrors: Record<string, string>
|
||||
availableVariables?: Variable[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:model-value', value: ConditionNodeData): void
|
||||
(e: 'validate-field', field: string): void
|
||||
}>()
|
||||
|
||||
// Available operators
|
||||
const operators = [
|
||||
{ value: '===', label: '等于' },
|
||||
{ value: '!==', label: '不等于' },
|
||||
{ value: '>', label: '大于' },
|
||||
{ value: '<', label: '小于' },
|
||||
{ value: '>=', label: '大于或等于' },
|
||||
{ value: '<=', label: '小于或等于' },
|
||||
{ value: 'includes', label: '包含' },
|
||||
{ value: 'startsWith', label: '开头是' },
|
||||
{ value: 'endsWith', label: '结尾是' }
|
||||
]
|
||||
|
||||
// Initialize condition groups from existing data or create default
|
||||
const conditionGroups = ref<ConditionGroup[]>(
|
||||
props.modelValue.condition_groups || [
|
||||
{
|
||||
id: generateId(),
|
||||
rules: [
|
||||
{
|
||||
id: generateId(),
|
||||
variable: '',
|
||||
operator: '===',
|
||||
value: ''
|
||||
}
|
||||
],
|
||||
logicalOperator: 'AND'
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
// Generate unique ID
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).substr(2, 9)
|
||||
}
|
||||
|
||||
// Factory for default condition group structure
|
||||
function createDefaultConditionGroups(): ConditionGroup[] {
|
||||
return [
|
||||
{
|
||||
id: generateId(),
|
||||
rules: [
|
||||
{ id: generateId(), variable: '', operator: '===', value: '' }
|
||||
],
|
||||
logicalOperator: 'AND'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Keep local state in sync when switching nodes or external updates occur
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue && Array.isArray(newValue.condition_groups)) {
|
||||
conditionGroups.value = newValue.condition_groups.length > 0
|
||||
? newValue.condition_groups
|
||||
: createDefaultConditionGroups()
|
||||
} else {
|
||||
conditionGroups.value = createDefaultConditionGroups()
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
// Update condition groups and generate expression
|
||||
const updateConditions = () => {
|
||||
const expression = generateConditionExpression()
|
||||
const updatedData: ConditionNodeData = {
|
||||
condition_expression: expression,
|
||||
condition_groups: conditionGroups.value
|
||||
}
|
||||
|
||||
emit('update:model-value', updatedData)
|
||||
emit('validate-field', 'condition_expression')
|
||||
emit('validate-field', 'condition_groups')
|
||||
}
|
||||
|
||||
// Generate JavaScript expression from condition groups
|
||||
const generateConditionExpression = (): string => {
|
||||
if (conditionGroups.value.length === 0) return ''
|
||||
|
||||
const groupExpressions = conditionGroups.value.map(group => {
|
||||
if (group.rules.length === 0) return ''
|
||||
|
||||
const ruleExpressions = group.rules
|
||||
.filter(rule => rule.variable && rule.operator && rule.value)
|
||||
.map(rule => {
|
||||
const variable = `{{${rule.variable}}}`
|
||||
const value = isNaN(Number(rule.value)) ? `"${rule.value}"` : rule.value
|
||||
|
||||
if (rule.operator === 'includes') {
|
||||
return `${variable}.includes(${value})`
|
||||
} else if (rule.operator === 'startsWith') {
|
||||
return `${variable}.startsWith(${value})`
|
||||
} else if (rule.operator === 'endsWith') {
|
||||
return `${variable}.endsWith(${value})`
|
||||
} else {
|
||||
return `${variable} ${rule.operator} ${value}`
|
||||
}
|
||||
})
|
||||
|
||||
if (ruleExpressions.length === 0) return ''
|
||||
if (ruleExpressions.length === 1) return ruleExpressions[0]
|
||||
|
||||
return `(${ruleExpressions.join(` ${group.logicalOperator} `)})`
|
||||
}).filter(expr => expr)
|
||||
|
||||
if (groupExpressions.length === 0) return ''
|
||||
if (groupExpressions.length === 1) return groupExpressions[0]
|
||||
|
||||
return groupExpressions.join(' OR ')
|
||||
}
|
||||
|
||||
// Add new rule to a group
|
||||
const addRule = (groupId: string) => {
|
||||
const group = conditionGroups.value.find(g => g.id === groupId)
|
||||
if (group) {
|
||||
group.rules.push({
|
||||
id: generateId(),
|
||||
variable: '',
|
||||
operator: '===',
|
||||
value: ''
|
||||
})
|
||||
updateConditions()
|
||||
}
|
||||
}
|
||||
|
||||
// Remove rule from a group
|
||||
const removeRule = (groupId: string, ruleId: string) => {
|
||||
const group = conditionGroups.value.find(g => g.id === groupId)
|
||||
if (group && group.rules.length > 1) {
|
||||
group.rules = group.rules.filter(r => r.id !== ruleId)
|
||||
updateConditions()
|
||||
}
|
||||
}
|
||||
|
||||
// Add new condition group
|
||||
const addGroup = () => {
|
||||
conditionGroups.value.push({
|
||||
id: generateId(),
|
||||
rules: [
|
||||
{
|
||||
id: generateId(),
|
||||
variable: '',
|
||||
operator: '===',
|
||||
value: ''
|
||||
}
|
||||
],
|
||||
logicalOperator: 'AND'
|
||||
})
|
||||
updateConditions()
|
||||
}
|
||||
|
||||
// Remove condition group
|
||||
const removeGroup = (groupId: string) => {
|
||||
if (conditionGroups.value.length > 1) {
|
||||
conditionGroups.value = conditionGroups.value.filter(g => g.id !== groupId)
|
||||
updateConditions()
|
||||
}
|
||||
}
|
||||
|
||||
// Update rule
|
||||
const updateRule = (groupId: string, ruleId: string, field: keyof ConditionRule, value: string) => {
|
||||
const group = conditionGroups.value.find(g => g.id === groupId)
|
||||
if (group) {
|
||||
const rule = group.rules.find(r => r.id === ruleId)
|
||||
if (rule) {
|
||||
rule[field] = value
|
||||
updateConditions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update group logical operator
|
||||
const updateGroupOperator = (groupId: string, operator: 'AND' | 'OR') => {
|
||||
const group = conditionGroups.value.find(g => g.id === groupId)
|
||||
if (group) {
|
||||
group.logicalOperator = operator
|
||||
updateConditions()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="condition-node-config">
|
||||
<!-- Condition Builder -->
|
||||
<div class="form-group">
|
||||
<label>条件规则 *</label>
|
||||
|
||||
<div v-if="!availableVariables || availableVariables.length === 0" class="no-variables">
|
||||
<p>暂无可用的变量。请在此条件节点之前添加表单或用户输入节点,以便在条件中使用它们的值。</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="condition-builder">
|
||||
<div
|
||||
v-for="(group, groupIndex) in conditionGroups"
|
||||
:key="group.id"
|
||||
class="condition-group"
|
||||
>
|
||||
<!-- Group Header -->
|
||||
<div v-if="groupIndex > 0" class="group-separator">
|
||||
<span class="separator-text">或</span>
|
||||
</div>
|
||||
|
||||
<div class="group-content">
|
||||
<!-- Rules within the group -->
|
||||
<div
|
||||
v-for="(rule, ruleIndex) in group.rules"
|
||||
:key="rule.id"
|
||||
class="condition-rule"
|
||||
>
|
||||
<!-- Rule logical operator (AND/OR) -->
|
||||
<div v-if="ruleIndex > 0" class="rule-operator">
|
||||
<select
|
||||
:value="group.logicalOperator"
|
||||
@change="updateGroupOperator(group.id, ($event.target as HTMLSelectElement).value as 'AND' | 'OR')"
|
||||
class="operator-select"
|
||||
>
|
||||
<option value="AND">AND</option>
|
||||
<option value="OR">OR</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Rule configuration -->
|
||||
<div class="rule-config">
|
||||
<!-- Variable selection -->
|
||||
<select
|
||||
:value="rule.variable"
|
||||
@change="updateRule(group.id, rule.id, 'variable', ($event.target as HTMLSelectElement).value)"
|
||||
class="variable-select"
|
||||
>
|
||||
<option value="">选择变量</option>
|
||||
<option
|
||||
v-for="variable in availableVariables"
|
||||
:key="variable.fieldName"
|
||||
:value="variable.fieldName"
|
||||
>
|
||||
{{ variable.fieldLabel }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Operator selection -->
|
||||
<select
|
||||
:value="rule.operator"
|
||||
@change="updateRule(group.id, rule.id, 'operator', ($event.target as HTMLSelectElement).value)"
|
||||
class="operator-select"
|
||||
>
|
||||
<option
|
||||
v-for="op in operators"
|
||||
:key="op.value"
|
||||
:value="op.value"
|
||||
>
|
||||
{{ op.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Value input -->
|
||||
<input
|
||||
type="text"
|
||||
:value="rule.value"
|
||||
@input="updateRule(group.id, rule.id, 'value', ($event.target as HTMLInputElement).value)"
|
||||
placeholder="输入值"
|
||||
class="value-input"
|
||||
/>
|
||||
|
||||
<!-- Remove rule button -->
|
||||
<button
|
||||
v-if="group.rules.length > 1"
|
||||
type="button"
|
||||
@click="removeRule(group.id, rule.id)"
|
||||
class="remove-btn rule-remove"
|
||||
title="删除规则"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add rule button -->
|
||||
<button
|
||||
type="button"
|
||||
@click="addRule(group.id)"
|
||||
class="add-btn add-rule"
|
||||
>
|
||||
+ 添加规则
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Remove group button -->
|
||||
<button
|
||||
v-if="conditionGroups.length > 1"
|
||||
type="button"
|
||||
@click="removeGroup(group.id)"
|
||||
class="remove-btn group-remove"
|
||||
title="删除分组"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add group button -->
|
||||
<button
|
||||
type="button"
|
||||
@click="addGroup"
|
||||
class="add-btn add-group"
|
||||
>
|
||||
+ 添加分组(或)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Generated expression preview -->
|
||||
<div v-if="generateConditionExpression()" class="expression-preview">
|
||||
<label>生成的表达式:</label>
|
||||
<code class="expression-code">{{ generateConditionExpression() }}</code>
|
||||
</div>
|
||||
|
||||
<div v-if="validationErrors.condition_expression" class="error-message">
|
||||
{{ validationErrors.condition_expression }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.condition-node-config {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.no-variables {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
margin: 8px 0;
|
||||
padding: 12px;
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.condition-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.condition-group {
|
||||
position: relative;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px;
|
||||
background: var(--background-soft);
|
||||
}
|
||||
|
||||
.group-separator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.separator-text {
|
||||
background: var(--background-color);
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.group-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.condition-rule {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rule-operator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rule-config {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.variable-select,
|
||||
.operator-select {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.value-input {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.variable-select:focus,
|
||||
.operator-select:focus,
|
||||
.value-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
background: rgba(201, 242, 78, 0.05);
|
||||
}
|
||||
|
||||
.add-rule {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.add-group {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
background: var(--error-dark, #dc2626);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.group-remove {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
}
|
||||
|
||||
.rule-remove {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.expression-preview {
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
background: var(--background-alt);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.expression-preview label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.expression-code {
|
||||
display: block;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color);
|
||||
background: var(--background-color);
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.error-message::before {
|
||||
content: "⚠";
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import type { Node } from '@vue-flow/core'
|
||||
|
||||
defineProps<{
|
||||
data: Node['data']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="end-node">
|
||||
<Handle
|
||||
id="input"
|
||||
type="target"
|
||||
:position="Position.Left"
|
||||
class="node-handle"
|
||||
/>
|
||||
|
||||
<div class="node-content">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">🏁</div>
|
||||
<div class="node-title">{{ data.label || '结束' }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.description" class="node-description">
|
||||
{{ data.description }}
|
||||
</div>
|
||||
|
||||
<div v-if="data.config?.endType" class="node-preview">
|
||||
<div class="preview-label">结束类型:</div>
|
||||
<div class="preview-content">{{ data.config.endType }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.config?.finalMessage" class="node-preview">
|
||||
<div class="preview-label">最终消息:</div>
|
||||
<div class="preview-content">
|
||||
{{ data.config.finalMessage.substring(0, 80) }}{{ data.config.finalMessage.length > 80 ? '...' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- End node doesn't have output handle -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.end-node {
|
||||
position: relative;
|
||||
background: var(--background-color);
|
||||
border: 2px solid #DC2626;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.end-node:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.node-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-preview {
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-handle {
|
||||
background: #DC2626;
|
||||
border: 2px solid var(--background-color);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.node-handle:hover {
|
||||
background: #B91C1C;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface EndNodeData {
|
||||
final_message: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: EndNodeData
|
||||
validationErrors: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:model-value', value: EndNodeData): void
|
||||
(e: 'validate-field', field: string): void
|
||||
}>()
|
||||
|
||||
const formData = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: EndNodeData) => emit('update:model-value', value)
|
||||
})
|
||||
|
||||
// Update form data
|
||||
const updateFormData = (field: keyof EndNodeData, value: any) => {
|
||||
formData.value = {
|
||||
...formData.value,
|
||||
[field]: value
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="end-node-config">
|
||||
<div class="form-group">
|
||||
<label for="end-message">最终消息</label>
|
||||
<textarea
|
||||
id="end-message"
|
||||
:value="formData.final_message"
|
||||
@input="updateFormData('final_message', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="form-textarea"
|
||||
placeholder="结束会话时显示的最终消息"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.end-node-config {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
transition: border-color 0.2s ease;
|
||||
resize: vertical;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
|
||||
}
|
||||
|
||||
.form-textarea.error {
|
||||
border-color: var(--error-color);
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.form-textarea.error:focus {
|
||||
border-color: var(--error-color);
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.error-message::before {
|
||||
content: "⚠";
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import type { Node } from '@vue-flow/core'
|
||||
|
||||
defineProps<{
|
||||
data: Node['data']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-node">
|
||||
<Handle
|
||||
id="input"
|
||||
type="target"
|
||||
:position="Position.Left"
|
||||
class="node-handle"
|
||||
/>
|
||||
|
||||
<div class="node-content">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">📝</div>
|
||||
<div class="node-title">{{ data.label || '表单' }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.description" class="node-description">
|
||||
{{ data.description }}
|
||||
</div>
|
||||
|
||||
<div v-if="data.config?.fields?.length" class="node-preview">
|
||||
<div class="preview-label">字段({{ data.config.fields.length }}):</div>
|
||||
<div class="field-list">
|
||||
<div
|
||||
v-for="field in data.config.fields.slice(0, 3)"
|
||||
:key="field.name"
|
||||
class="field-item"
|
||||
>
|
||||
{{ field.name }} ({{ field.type }})
|
||||
</div>
|
||||
<div v-if="data.config.fields.length > 3" class="field-more">
|
||||
+{{ data.config.fields.length - 3 }} 项
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
id="output"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="node-handle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-node {
|
||||
position: relative;
|
||||
background: var(--background-color);
|
||||
border: 2px solid #10B981;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-node:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.node-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-preview {
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.field-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.field-item {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-color);
|
||||
padding: 2px 4px;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.field-more {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.node-handle {
|
||||
background: #10B981;
|
||||
border: 2px solid var(--background-color);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.node-handle:hover {
|
||||
background: #059669;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,604 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
// Define form field interface
|
||||
interface FormField {
|
||||
name: string
|
||||
label: string
|
||||
type: string
|
||||
required: boolean
|
||||
placeholder: string
|
||||
options: string | string[] // Can be string (for textarea display) or array (when saved)
|
||||
minLength: number
|
||||
maxLength: number
|
||||
}
|
||||
|
||||
interface FormNodeData {
|
||||
form_title: string
|
||||
form_description: string
|
||||
submit_button_text: string
|
||||
form_full_screen: boolean
|
||||
form_fields: FormField[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: FormNodeData
|
||||
validationErrors: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:model-value', value: FormNodeData): void
|
||||
(e: 'validate-field', field: string): void
|
||||
}>()
|
||||
|
||||
const formData = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: FormNodeData) => emit('update:model-value', value)
|
||||
})
|
||||
|
||||
// Get field type icon
|
||||
const getFieldTypeIcon = (type: string) => {
|
||||
const icons = {
|
||||
text: '📝',
|
||||
email: '📧',
|
||||
number: '🔢',
|
||||
tel: '📞',
|
||||
textarea: '📄',
|
||||
select: '📋',
|
||||
checkbox: '☑️',
|
||||
radio: '🔘'
|
||||
}
|
||||
return icons[type as keyof typeof icons] || '📝'
|
||||
}
|
||||
|
||||
// Add form field
|
||||
const addFormField = () => {
|
||||
const updatedFields = [...formData.value.form_fields, {
|
||||
name: '',
|
||||
label: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
placeholder: '',
|
||||
options: '',
|
||||
minLength: 0,
|
||||
maxLength: 255
|
||||
}]
|
||||
|
||||
formData.value = {
|
||||
...formData.value,
|
||||
form_fields: updatedFields
|
||||
}
|
||||
|
||||
// Trigger validation for form fields
|
||||
emit('validate-field', 'form_fields')
|
||||
}
|
||||
|
||||
// Remove form field
|
||||
const removeFormField = (index: number) => {
|
||||
const updatedFields = formData.value.form_fields.filter((_, i) => i !== index)
|
||||
|
||||
formData.value = {
|
||||
...formData.value,
|
||||
form_fields: updatedFields
|
||||
}
|
||||
|
||||
// Trigger validation for form fields
|
||||
emit('validate-field', 'form_fields')
|
||||
}
|
||||
|
||||
// Update individual field
|
||||
const updateField = (index: number, field: string, value: any) => {
|
||||
const updatedFields = [...formData.value.form_fields]
|
||||
updatedFields[index] = {
|
||||
...updatedFields[index],
|
||||
[field]: value
|
||||
}
|
||||
|
||||
formData.value = {
|
||||
...formData.value,
|
||||
form_fields: updatedFields
|
||||
}
|
||||
|
||||
emit('validate-field', 'form_fields')
|
||||
}
|
||||
|
||||
// Update form data
|
||||
const updateFormData = (field: keyof FormNodeData, value: any) => {
|
||||
formData.value = {
|
||||
...formData.value,
|
||||
[field]: value
|
||||
}
|
||||
|
||||
if (field !== 'form_fields') {
|
||||
emit('validate-field', field)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-node-config">
|
||||
<div class="form-group">
|
||||
<label for="form-title">表单标题</label>
|
||||
<input
|
||||
id="form-title"
|
||||
:value="formData.form_title"
|
||||
@input="updateFormData('form_title', ($event.target as HTMLInputElement).value)"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="输入表单标题"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="form-description">表单描述</label>
|
||||
<textarea
|
||||
id="form-description"
|
||||
:value="formData.form_description"
|
||||
@input="updateFormData('form_description', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="form-textarea"
|
||||
placeholder="输入表单描述(可选)"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="submit-button-text">提交按钮文字</label>
|
||||
<input
|
||||
id="submit-button-text"
|
||||
:value="formData.submit_button_text"
|
||||
@input="updateFormData('submit_button_text', ($event.target as HTMLInputElement).value)"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="提交"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-group">
|
||||
<input
|
||||
:checked="formData.form_full_screen"
|
||||
@change="updateFormData('form_full_screen', ($event.target as HTMLInputElement).checked)"
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
<span class="checkbox-label">全屏显示表单</span>
|
||||
</label>
|
||||
<p class="help-text">启用后,表单将以全屏浮层显示,而不是显示在聊天界面内</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>表单字段</label>
|
||||
<div class="form-fields-container" :class="{ 'error': validationErrors.form_fields }">
|
||||
<div
|
||||
v-for="(field, index) in formData.form_fields"
|
||||
:key="index"
|
||||
class="form-field-item"
|
||||
>
|
||||
<div class="form-field-header">
|
||||
<span class="field-type-badge" :class="`field-type-${field.type}`">
|
||||
{{ getFieldTypeIcon(field.type) }} {{ field.type }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-field-btn"
|
||||
@click="removeFormField(index)"
|
||||
title="删除字段"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-field-config">
|
||||
<div class="field-row">
|
||||
<div class="field-col">
|
||||
<label class="field-label">字段名 *</label>
|
||||
<input
|
||||
:value="field.name"
|
||||
@input="updateField(index, 'name', ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('validate-field', 'form_fields')"
|
||||
type="text"
|
||||
class="field-input"
|
||||
placeholder="field_name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field-col">
|
||||
<label class="field-label">显示名称 *</label>
|
||||
<input
|
||||
:value="field.label"
|
||||
@input="updateField(index, 'label', ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('validate-field', 'form_fields')"
|
||||
type="text"
|
||||
class="field-input"
|
||||
placeholder="字段名称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-col">
|
||||
<label class="field-label">字段类型</label>
|
||||
<select
|
||||
:value="field.type"
|
||||
@change="updateField(index, 'type', ($event.target as HTMLSelectElement).value)"
|
||||
class="field-select"
|
||||
>
|
||||
<option value="text">文本</option>
|
||||
<option value="email">邮箱</option>
|
||||
<option value="number">数字</option>
|
||||
<option value="tel">电话</option>
|
||||
<option value="textarea">多行文本</option>
|
||||
<option value="select">下拉选择</option>
|
||||
<option value="checkbox">复选框</option>
|
||||
<option value="radio">单选框</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-col">
|
||||
<label class="field-label">必填</label>
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
:checked="field.required"
|
||||
@change="updateField(index, 'required', ($event.target as HTMLInputElement).checked)"
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
<span>必填字段</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-col-full">
|
||||
<label class="field-label">占位提示</label>
|
||||
<input
|
||||
:value="field.placeholder"
|
||||
@input="updateField(index, 'placeholder', ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('validate-field', 'form_fields')"
|
||||
type="text"
|
||||
class="field-input"
|
||||
placeholder="输入占位提示文字"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options for select/radio fields -->
|
||||
<div v-if="field.type === 'select' || field.type === 'radio'" class="field-row">
|
||||
<div class="field-col-full">
|
||||
<label class="field-label">选项(每行一个)</label>
|
||||
<textarea
|
||||
:value="Array.isArray(field.options) ? field.options.join('\n') : field.options"
|
||||
@input="updateField(index, 'options', ($event.target as HTMLTextAreaElement).value)"
|
||||
@blur="$emit('validate-field', 'form_fields')"
|
||||
class="field-textarea"
|
||||
placeholder="选项 1 选项 2 选项 3"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation for text fields -->
|
||||
<div v-if="field.type === 'text' || field.type === 'textarea'" class="field-row">
|
||||
<div class="field-col">
|
||||
<label class="field-label">最小长度</label>
|
||||
<input
|
||||
:value="field.minLength"
|
||||
@input="updateField(index, 'minLength', parseInt(($event.target as HTMLInputElement).value) || 0)"
|
||||
@blur="$emit('validate-field', 'form_fields')"
|
||||
type="number"
|
||||
class="field-input"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-col">
|
||||
<label class="field-label">最大长度</label>
|
||||
<input
|
||||
:value="field.maxLength"
|
||||
@input="updateField(index, 'maxLength', parseInt(($event.target as HTMLInputElement).value) || 255)"
|
||||
@blur="$emit('validate-field', 'form_fields')"
|
||||
type="number"
|
||||
class="field-input"
|
||||
min="1"
|
||||
placeholder="255"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="add-field-btn"
|
||||
@click="addFormField"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
添加表单字段
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="validationErrors.form_fields" class="error-message">
|
||||
{{ validationErrors.form_fields }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-node-config {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color);
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.error-message::before {
|
||||
content: "⚠";
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Form Fields Styles */
|
||||
.form-fields-container {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-color);
|
||||
padding: var(--space-sm);
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.form-fields-container.error {
|
||||
border-color: var(--error-color);
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.form-field-item {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-soft);
|
||||
margin-bottom: var(--space-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-field-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-field-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--background-muted);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.field-type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.remove-field-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.remove-field-btn:hover {
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.remove-field-btn svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.form-field-config {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.field-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.field-col {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.field-col-full {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.field-input,
|
||||
.field-textarea,
|
||||
.field-select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.75rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.field-input:focus,
|
||||
.field-textarea:focus,
|
||||
.field-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 1px rgba(201, 242, 78, 0.15);
|
||||
}
|
||||
|
||||
.field-input.error,
|
||||
.field-textarea.error,
|
||||
.field-select.error {
|
||||
border-color: var(--error-color);
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.field-textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.add-field-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
width: 100%;
|
||||
padding: var(--space-sm);
|
||||
background: var(--background-muted);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.add-field-btn:hover {
|
||||
background: var(--primary-soft);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.add-field-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,534 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface GuardrailsNodeData {
|
||||
enabled_guardrails: string[]
|
||||
pii_action: string
|
||||
jailbreak_sensitivity: number
|
||||
text_source: string
|
||||
block_message: string
|
||||
}
|
||||
|
||||
interface Variable {
|
||||
nodeId: string
|
||||
nodeName: string
|
||||
fieldName: string
|
||||
fieldType: string
|
||||
fieldLabel: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: GuardrailsNodeData
|
||||
validationErrors: Record<string, string>
|
||||
availableVariables?: Variable[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:model-value', value: GuardrailsNodeData): void
|
||||
(e: 'validate-field', field: string): void
|
||||
}>()
|
||||
|
||||
const formData = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: GuardrailsNodeData) => emit('update:model-value', value)
|
||||
})
|
||||
|
||||
// Update form data
|
||||
const updateFormData = (field: keyof GuardrailsNodeData, value: any) => {
|
||||
formData.value = {
|
||||
...formData.value,
|
||||
[field]: value
|
||||
}
|
||||
|
||||
emit('validate-field', field)
|
||||
}
|
||||
|
||||
// Toggle guardrail type
|
||||
const toggleGuardrail = (type: string) => {
|
||||
const current = formData.value.enabled_guardrails || []
|
||||
const index = current.indexOf(type)
|
||||
|
||||
let updated: string[]
|
||||
if (index > -1) {
|
||||
// Remove if exists
|
||||
updated = current.filter(g => g !== type)
|
||||
} else {
|
||||
// Add if doesn't exist
|
||||
updated = [...current, type]
|
||||
}
|
||||
|
||||
updateFormData('enabled_guardrails', updated)
|
||||
}
|
||||
|
||||
const isGuardrailEnabled = (type: string) => {
|
||||
return (formData.value.enabled_guardrails || []).includes(type)
|
||||
}
|
||||
|
||||
// Helper to get variable syntax
|
||||
const getVariableSyntax = (fieldName: string) => {
|
||||
return `{{${fieldName}}}`
|
||||
}
|
||||
|
||||
// Copy variable to clipboard
|
||||
const copyVariableToClipboard = async (variable: Variable) => {
|
||||
const syntax = getVariableSyntax(variable.fieldName)
|
||||
try {
|
||||
await navigator.clipboard.writeText(syntax)
|
||||
// Could add a toast notification here if needed
|
||||
} catch (err) {
|
||||
console.error('Failed to copy variable:', err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="guardrails-node-config">
|
||||
<div class="info-box">
|
||||
<p>配置内容护栏,检测并拦截不当内容。</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>启用的护栏 *</label>
|
||||
<div class="guardrail-options">
|
||||
<label class="guardrail-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isGuardrailEnabled('pii')"
|
||||
@change="toggleGuardrail('pii')"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
<div class="guardrail-info">
|
||||
<span class="guardrail-icon">🔒</span>
|
||||
<div>
|
||||
<strong>PII 检测</strong>
|
||||
<p>检测个人身份信息(邮箱、电话号码、身份证号等)</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="guardrail-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isGuardrailEnabled('jailbreak')"
|
||||
@change="toggleGuardrail('jailbreak')"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
<div class="guardrail-info">
|
||||
<span class="guardrail-icon">⚠️</span>
|
||||
<div>
|
||||
<strong>越狱检测</strong>
|
||||
<p>检测绕过 AI 安全准则或操纵系统提示词的尝试</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="validationErrors.enabled_guardrails" class="error-message">
|
||||
{{ validationErrors.enabled_guardrails }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PII Action -->
|
||||
<div v-if="isGuardrailEnabled('pii')" class="form-group">
|
||||
<label for="pii-action">PII 处理方式</label>
|
||||
<select
|
||||
id="pii-action"
|
||||
:value="formData.pii_action"
|
||||
@change="updateFormData('pii_action', ($event.target as HTMLSelectElement).value)"
|
||||
class="form-select"
|
||||
>
|
||||
<option value="block">阻止:检测到 PII 时停止处理</option>
|
||||
<option value="redact">脱敏:将 PII 替换为占位符</option>
|
||||
<option value="warning">警告:记录日志但允许继续处理</option>
|
||||
<option value="log">记录:仅记录检测结果</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Jailbreak Sensitivity -->
|
||||
<div v-if="isGuardrailEnabled('jailbreak')" class="form-group">
|
||||
<label for="jailbreak-sensitivity">
|
||||
越狱检测灵敏度:{{ formData.jailbreak_sensitivity?.toFixed(1) || '0.7' }}
|
||||
</label>
|
||||
<input
|
||||
id="jailbreak-sensitivity"
|
||||
type="range"
|
||||
:value="formData.jailbreak_sensitivity || 0.7"
|
||||
@input="updateFormData('jailbreak_sensitivity', parseFloat(($event.target as HTMLInputElement).value))"
|
||||
min="0.1"
|
||||
max="1.0"
|
||||
step="0.1"
|
||||
class="form-range"
|
||||
/>
|
||||
<div class="range-labels">
|
||||
<span>较低灵敏度(0.1)</span>
|
||||
<span>较高灵敏度(1.0)</span>
|
||||
</div>
|
||||
<small class="help-text">
|
||||
灵敏度越高,越容易检测到潜在的越狱尝试
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="text-source">文本来源</label>
|
||||
<input
|
||||
id="text-source"
|
||||
type="text"
|
||||
:value="formData.text_source"
|
||||
@input="updateFormData('text_source', ($event.target as HTMLInputElement).value)"
|
||||
class="form-input"
|
||||
placeholder="user_message or user_input_input"
|
||||
/>
|
||||
<small class="help-text">
|
||||
指定要检测的文本。使用“user_message”表示最新用户输入,也可引用“user_input_input”这样的变量,或使用双花括号语法
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Available Variables Section -->
|
||||
<div v-if="availableVariables && availableVariables.length > 0" class="variables-section">
|
||||
<div class="variables-header">
|
||||
<span class="variables-title">可用变量</span>
|
||||
<span class="variables-count">{{ availableVariables.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="variables-list">
|
||||
<div
|
||||
v-for="variable in availableVariables"
|
||||
:key="`${variable.nodeId}-${variable.fieldName}`"
|
||||
class="variable-item"
|
||||
@click="copyVariableToClipboard(variable)"
|
||||
:title="`点击复制 ${getVariableSyntax(variable.fieldName)} 到剪贴板`"
|
||||
>
|
||||
<div class="variable-info">
|
||||
<div class="variable-name">{{ variable.fieldName }}</div>
|
||||
<div class="variable-source">来自 {{ variable.nodeName }}</div>
|
||||
</div>
|
||||
<div class="variable-syntax">
|
||||
<code>{{ getVariableSyntax(variable.fieldName) }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="variables-help">
|
||||
<small>点击任意变量即可复制到剪贴板</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="block-message">自定义拦截消息</label>
|
||||
<textarea
|
||||
id="block-message"
|
||||
:value="formData.block_message"
|
||||
@input="updateFormData('block_message', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="form-textarea"
|
||||
placeholder="可选:内容被拦截时显示的自定义消息"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<small class="help-text">
|
||||
留空则使用默认拦截消息
|
||||
</small>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.guardrails-node-config {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--color-bg-tertiary, #f8f9fa);
|
||||
border-left: 3px solid var(--color-primary, #3b82f6);
|
||||
padding: var(--space-xs);
|
||||
margin-bottom: var(--space-sm);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.info-box ul {
|
||||
margin: var(--space-xs) 0 0 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.info-box li {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.routing-info {
|
||||
border-left-color: var(--color-accent, #ec4899);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #1f2937);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.guardrail-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.guardrail-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.guardrail-option:hover {
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
.guardrail-option input[type="checkbox"] {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.guardrail-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.guardrail-icon {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.guardrail-info strong {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.guardrail-info p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: var(--space-xs);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
.form-range {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg-tertiary, #e5e7eb);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.form-range::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #3b82f6);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-range::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #3b82f6);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.range-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.range-labels span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--color-error, #ef4444);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Variables Section */
|
||||
.variables-section {
|
||||
margin-top: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: var(--background-soft);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.variables-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.variables-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.variables-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-radius: 10px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.variables-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.variable-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm);
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.variable-item:hover {
|
||||
background: var(--background-alt);
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.variable-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.variable-name {
|
||||
font-weight: 600;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.variable-source {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.variable-syntax {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
|
||||
.variable-syntax code {
|
||||
background: var(--background-soft);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.variables-help {
|
||||
padding: var(--space-sm);
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.variables-help small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import type { Node } from '@vue-flow/core'
|
||||
|
||||
defineProps<{
|
||||
data: Node['data']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="human-transfer-node">
|
||||
<Handle
|
||||
id="input"
|
||||
type="target"
|
||||
:position="Position.Left"
|
||||
class="node-handle"
|
||||
/>
|
||||
|
||||
<div class="node-content">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">👤</div>
|
||||
<div class="node-title">{{ data.label || '转人工' }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.description" class="node-description">
|
||||
{{ data.description }}
|
||||
</div>
|
||||
|
||||
<div v-if="data.config?.department" class="node-preview">
|
||||
<div class="preview-label">部门:</div>
|
||||
<div class="preview-content">{{ data.config.department }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.config?.message" class="node-preview">
|
||||
<div class="preview-label">转接消息:</div>
|
||||
<div class="preview-content">
|
||||
{{ data.config.message.substring(0, 80) }}{{ data.config.message.length > 80 ? '...' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
id="output"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="node-handle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.human-transfer-node {
|
||||
position: relative;
|
||||
background: var(--background-color);
|
||||
border: 2px solid #F97316;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.human-transfer-node:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.node-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-preview {
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-handle {
|
||||
background: #F97316;
|
||||
border: 2px solid var(--background-color);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.node-handle:hover {
|
||||
background: #EA580C;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface HumanTransferNodeData {
|
||||
transfer_department: string
|
||||
transfer_message: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: HumanTransferNodeData
|
||||
validationErrors: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:model-value', value: HumanTransferNodeData): void
|
||||
(e: 'validate-field', field: string): void
|
||||
}>()
|
||||
|
||||
const formData = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: HumanTransferNodeData) => emit('update:model-value', value)
|
||||
})
|
||||
|
||||
// Update form data
|
||||
const updateFormData = (field: keyof HumanTransferNodeData, value: any) => {
|
||||
formData.value = {
|
||||
...formData.value,
|
||||
[field]: value
|
||||
}
|
||||
|
||||
emit('validate-field', field)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="human-transfer-node-config">
|
||||
<div class="form-group">
|
||||
<label for="transfer-department">部门 *</label>
|
||||
<select
|
||||
id="transfer-department"
|
||||
:value="formData.transfer_department"
|
||||
@change="updateFormData('transfer_department', ($event.target as HTMLSelectElement).value)"
|
||||
@blur="$emit('validate-field', 'transfer_department')"
|
||||
class="form-select"
|
||||
:class="{ 'error': validationErrors.transfer_department }"
|
||||
required
|
||||
>
|
||||
<option value="">选择部门</option>
|
||||
<option value="general">常规支持</option>
|
||||
<option value="sales">销售</option>
|
||||
<option value="technical">技术支持</option>
|
||||
</select>
|
||||
<div v-if="validationErrors.transfer_department" class="error-message">
|
||||
{{ validationErrors.transfer_department }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="transfer-message">转接消息</label>
|
||||
<textarea
|
||||
id="transfer-message"
|
||||
:value="formData.transfer_message"
|
||||
@input="updateFormData('transfer_message', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="form-textarea"
|
||||
placeholder="转接至人工客服时显示的消息"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.human-transfer-node-config {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-select:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
|
||||
}
|
||||
|
||||
.form-select.error,
|
||||
.form-textarea.error {
|
||||
border-color: var(--error-color);
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.form-select.error:focus,
|
||||
.form-textarea.error:focus {
|
||||
border-color: var(--error-color);
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.error-message::before {
|
||||
content: "⚠";
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import type { Node } from '@vue-flow/core'
|
||||
|
||||
defineProps<{
|
||||
data: Node['data']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="llm-node">
|
||||
<Handle
|
||||
id="input"
|
||||
type="target"
|
||||
:position="Position.Left"
|
||||
class="node-handle"
|
||||
/>
|
||||
|
||||
<div class="node-content">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">🤖</div>
|
||||
<div class="node-title">{{ data.label || 'LLM' }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.description" class="node-description">
|
||||
{{ data.description }}
|
||||
</div>
|
||||
|
||||
<div v-if="data.config?.model" class="node-preview">
|
||||
<div class="preview-label">模型:</div>
|
||||
<div class="preview-content">{{ data.config.model }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.config?.systemPrompt" class="node-preview">
|
||||
<div class="preview-label">系统提示词:</div>
|
||||
<div class="preview-content">
|
||||
{{ data.config.systemPrompt.substring(0, 80) }}{{ data.config.systemPrompt.length > 80 ? '...' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
id="output"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="node-handle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.llm-node {
|
||||
position: relative;
|
||||
background: var(--background-color);
|
||||
border: 2px solid #8B5CF6;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.llm-node:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.node-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-preview {
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-handle {
|
||||
background: #8B5CF6;
|
||||
border: 2px solid var(--background-color);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.node-handle:hover {
|
||||
background: #7C3AED;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,859 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAgentEdit } from '@/composables/useAgentEdit'
|
||||
import KnowledgeGrid from '@/components/agent/KnowledgeGrid.vue'
|
||||
import { ExitCondition } from '@/types/workflow'
|
||||
import type { UserGroup } from '@/types/user'
|
||||
|
||||
interface FormData {
|
||||
system_prompt: string
|
||||
temperature: number
|
||||
exit_condition: string
|
||||
auto_transfer_enabled: boolean
|
||||
transfer_group_id: string
|
||||
ask_for_rating: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: FormData
|
||||
validationErrors: Record<string, string>
|
||||
userGroups: UserGroup[]
|
||||
loadingGroups: boolean
|
||||
agentId: string
|
||||
organizationId: string
|
||||
collapsedSections: Record<string, boolean>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: FormData): void
|
||||
(e: 'validateField', field: string): void
|
||||
(e: 'toggleSection', section: string): void
|
||||
}>()
|
||||
|
||||
// Local reactive copy of the form data
|
||||
const localFormData = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
// AI generation state
|
||||
const showAIPrompt = ref(false)
|
||||
const aiPrompt = ref('')
|
||||
|
||||
// Initialize agent edit composable (using a mock agent object for AI generation)
|
||||
const mockAgent = {
|
||||
id: 'temp-agent',
|
||||
name: 'Workflow Node Agent',
|
||||
display_name: 'Workflow Node Agent',
|
||||
description: 'Temporary agent for AI generation',
|
||||
agent_type: 'general',
|
||||
instructions: '',
|
||||
transfer_to_human: false,
|
||||
ask_for_rating: false,
|
||||
organization_id: 'temp',
|
||||
created_by: 'temp',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
is_active: true,
|
||||
enable_rate_limiting: false,
|
||||
overall_limit_per_ip: 0,
|
||||
requests_per_sec: 0,
|
||||
conversation_starters: [],
|
||||
knowledge_base_ids: [],
|
||||
canvas_data: {}
|
||||
} as any
|
||||
|
||||
const { generateInstructions, isLoading: aiLoading, error: aiError } = useAgentEdit(mockAgent)
|
||||
|
||||
// Handle field updates
|
||||
const updateField = (field: keyof FormData, value: any) => {
|
||||
const updated = { ...localFormData.value, [field]: value }
|
||||
emit('update:modelValue', updated)
|
||||
emit('validateField', field)
|
||||
}
|
||||
|
||||
// Handle AI generation for system prompt
|
||||
const handleGenerateWithAI = async () => {
|
||||
if (!aiPrompt.value.trim()) return
|
||||
|
||||
try {
|
||||
const generatedInstructions = await generateInstructions(aiPrompt.value)
|
||||
if (generatedInstructions.length > 0) {
|
||||
// Join the generated instructions with newlines
|
||||
updateField('system_prompt', generatedInstructions.join('\n'))
|
||||
showAIPrompt.value = false
|
||||
aiPrompt.value = ''
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to generate instructions:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const validateFieldOnChange = (field: string) => {
|
||||
emit('validateField', field)
|
||||
}
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
emit('toggleSection', section)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="llm-node-config">
|
||||
<div class="form-group">
|
||||
<div class="instructions-header">
|
||||
<label for="system-prompt">指令 *</label>
|
||||
<button
|
||||
class="ai-generate-button"
|
||||
@click="showAIPrompt = true"
|
||||
:disabled="aiLoading"
|
||||
type="button"
|
||||
>
|
||||
<span class="ai-icon">✨</span>
|
||||
AI 生成
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="system-prompt"
|
||||
:value="localFormData.system_prompt"
|
||||
@input="updateField('system_prompt', ($event.target as HTMLInputElement).value)"
|
||||
class="form-textarea"
|
||||
:class="{ 'error': validationErrors.system_prompt }"
|
||||
placeholder="输入给 AI 的系统提示词"
|
||||
rows="4"
|
||||
required
|
||||
@blur="validateFieldOnChange('system_prompt')"
|
||||
></textarea>
|
||||
<div v-if="validationErrors.system_prompt" class="error-message">
|
||||
{{ validationErrors.system_prompt }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="temperature">温度</label>
|
||||
<input
|
||||
id="temperature"
|
||||
:value="localFormData.temperature"
|
||||
@input="updateField('temperature', parseFloat(($event.target as HTMLInputElement).value))"
|
||||
type="number"
|
||||
class="form-input"
|
||||
:class="{ 'error': validationErrors.temperature }"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
placeholder="0.7"
|
||||
@blur="validateFieldOnChange('temperature')"
|
||||
/>
|
||||
<div v-if="validationErrors.temperature" class="error-message">
|
||||
{{ validationErrors.temperature }}
|
||||
</div>
|
||||
<p class="help-text">
|
||||
控制回复的随机性。数值越低(0.1)越聚焦,数值越高(1.5)越有创造性。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="exit-condition">退出条件 *</label>
|
||||
<select
|
||||
id="exit-condition"
|
||||
:value="localFormData.exit_condition"
|
||||
@change="updateField('exit_condition', ($event.target as HTMLSelectElement).value)"
|
||||
class="form-select"
|
||||
:class="{ 'error': validationErrors.exit_condition }"
|
||||
required
|
||||
@blur="validateFieldOnChange('exit_condition')"
|
||||
>
|
||||
<option :value="ExitCondition.SINGLE_EXECUTION">单次执行</option>
|
||||
<option :value="ExitCondition.CONTINUOUS_EXECUTION">持续执行</option>
|
||||
</select>
|
||||
<div v-if="validationErrors.exit_condition" class="error-message">
|
||||
{{ validationErrors.exit_condition }}
|
||||
</div>
|
||||
<p class="help-text">
|
||||
<template v-if="localFormData.exit_condition === ExitCondition.SINGLE_EXECUTION">
|
||||
AI 回复一次后总是进入下一个节点(默认)。
|
||||
</template>
|
||||
<template v-else-if="localFormData.exit_condition === ExitCondition.CONTINUOUS_EXECUTION">
|
||||
停留在当前节点持续对话,直到请求结束会话。
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Transfer to Human Setting - Only for Continuous Execution -->
|
||||
<div v-if="localFormData.exit_condition === ExitCondition.CONTINUOUS_EXECUTION" class="form-group">
|
||||
<label class="checkbox-group">
|
||||
<input
|
||||
:checked="localFormData.auto_transfer_enabled"
|
||||
@change="updateField('auto_transfer_enabled', ($event.target as HTMLInputElement).checked)"
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
<span class="checkbox-label">启用自动转人工</span>
|
||||
</label>
|
||||
<p class="help-text">
|
||||
启用后,AI 判断需要人工协助时可自动将会话转接给人工客服,且不会结束会话。
|
||||
</p>
|
||||
|
||||
<!-- Group selection - Only when transfer is enabled -->
|
||||
<div v-if="localFormData.auto_transfer_enabled" class="transfer-groups">
|
||||
<label for="transfer-group">转接分组</label>
|
||||
<div v-if="!loadingGroups">
|
||||
<select
|
||||
id="transfer-group"
|
||||
:value="localFormData.transfer_group_id"
|
||||
@change="updateField('transfer_group_id', ($event.target as HTMLSelectElement).value)"
|
||||
class="form-select"
|
||||
>
|
||||
<option value="">选择分组</option>
|
||||
<option v-for="group in userGroups" :key="group.id" :value="group.id">
|
||||
{{ group.name }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="userGroups.length === 0" class="help-text no-groups-message">
|
||||
暂无可用分组。
|
||||
<router-link to="/human-agents" class="create-group-link">
|
||||
创建分组
|
||||
</router-link>
|
||||
</p>
|
||||
<p v-else class="help-text">
|
||||
选择负责处理转接会话的分组。
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="loading-groups">
|
||||
正在加载分组……
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request Rating Setting - Only for Continuous Execution -->
|
||||
<div v-if="localFormData.exit_condition === ExitCondition.CONTINUOUS_EXECUTION" class="form-group">
|
||||
<label class="checkbox-group">
|
||||
<input
|
||||
:checked="localFormData.ask_for_rating"
|
||||
@change="updateField('ask_for_rating', ($event.target as HTMLInputElement).checked)"
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
<span class="checkbox-label">会话结束后邀请评分</span>
|
||||
</label>
|
||||
<p class="help-text">
|
||||
启用后,会话结束时将邀请客户对体验进行评分。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Knowledge Section -->
|
||||
<div class="form-group">
|
||||
<div class="knowledge-section">
|
||||
<div class="section-header knowledge-header" @click="toggleSection('knowledge')">
|
||||
<div class="section-title">
|
||||
<svg class="section-icon" :class="{ 'rotated': collapsedSections.knowledge }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6,9 12,15 18,9"></polyline>
|
||||
</svg>
|
||||
<span>知识来源</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-content knowledge-content" :class="{ 'collapsed': collapsedSections.knowledge }">
|
||||
<div class="knowledge-wrapper">
|
||||
<KnowledgeGrid
|
||||
v-if="!collapsedSections.knowledge"
|
||||
:agent-id="agentId"
|
||||
:organization-id="organizationId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Prompt Modal -->
|
||||
<div v-if="showAIPrompt" class="ai-prompt-modal">
|
||||
<div class="ai-prompt-content">
|
||||
<h5>用 AI 生成指令</h5>
|
||||
<textarea
|
||||
v-model="aiPrompt"
|
||||
placeholder="描述你希望这个 AI 节点做什么。例如:“为一个帮助查询订单的客服助手生成指令”"
|
||||
rows="4"
|
||||
class="ai-prompt-textarea"
|
||||
></textarea>
|
||||
<div v-if="aiError" class="error-message">{{ aiError }}</div>
|
||||
<div class="ai-prompt-actions">
|
||||
<button
|
||||
class="cancel-ai-button"
|
||||
@click="showAIPrompt = false"
|
||||
:disabled="aiLoading"
|
||||
type="button"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="generate-ai-button"
|
||||
@click="handleGenerateWithAI"
|
||||
:disabled="aiLoading || !aiPrompt.trim()"
|
||||
type="button"
|
||||
>
|
||||
{{ aiLoading ? '生成中……' : '生成' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Import shared form styles */
|
||||
.form-group {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
|
||||
}
|
||||
|
||||
.form-input.error,
|
||||
.form-textarea.error,
|
||||
.form-select.error {
|
||||
border-color: var(--error-color);
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.form-input.error:focus,
|
||||
.form-textarea.error:focus,
|
||||
.form-select.error:focus {
|
||||
border-color: var(--error-color);
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.error-message::before {
|
||||
content: "⚠";
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Instructions Header Styles */
|
||||
.instructions-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ai-generate-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.ai-generate-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.ai-generate-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ai-icon {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* AI Prompt Modal Styles */
|
||||
.ai-prompt-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.ai-prompt-content {
|
||||
background: var(--background-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.ai-prompt-content h5 {
|
||||
margin: 0 0 var(--space-sm) 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.ai-prompt-textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-soft);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ai-prompt-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.cancel-ai-button,
|
||||
.generate-ai-button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.85rem;
|
||||
min-width: 80px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cancel-ai-button {
|
||||
background: var(--background-muted);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cancel-ai-button:hover {
|
||||
background: var(--background-alt);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.generate-ai-button {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.generate-ai-button:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.generate-ai-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Transfer groups styling */
|
||||
.transfer-groups {
|
||||
margin-top: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--background-alt);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.transfer-groups label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.no-groups-message {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.create-group-link {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.create-group-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.loading-groups {
|
||||
text-align: center;
|
||||
padding: var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Knowledge Section Styles */
|
||||
.knowledge-section {
|
||||
margin-top: var(--space-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.knowledge-header .section-header {
|
||||
background: var(--background-muted);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.knowledge-header .section-header:hover {
|
||||
background: var(--background-alt);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.section-icon.rotated {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.knowledge-content {
|
||||
padding: 0;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.knowledge-content.collapsed {
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.knowledge-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Override KnowledgeGrid styles within LLMNodeConfig */
|
||||
.knowledge-content :deep(.knowledge-grid-container) {
|
||||
padding: var(--space-sm);
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.knowledge-header) {
|
||||
margin-bottom: var(--space-sm);
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.header-left) {
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.header-left h3) {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.header-actions) {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.action-button) {
|
||||
font-size: 0.7rem;
|
||||
padding: 4px 8px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.knowledge-grid) {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.knowledge-grid-header),
|
||||
.knowledge-content :deep(.knowledge-grid-row) {
|
||||
grid-template-columns: 1fr 60px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.header-cell),
|
||||
.knowledge-content :deep(.grid-cell) {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.header-cell:nth-child(2)),
|
||||
.knowledge-content :deep(.header-cell:nth-child(3)),
|
||||
.knowledge-content :deep(.header-cell:nth-child(4)),
|
||||
.knowledge-content :deep(.grid-cell:nth-child(2)),
|
||||
.knowledge-content :deep(.grid-cell:nth-child(3)),
|
||||
.knowledge-content :deep(.grid-cell:nth-child(4)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.actions-cell) {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.delete-button) {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.delete-icon) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.knowledge-empty) {
|
||||
padding: var(--space-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.warning-message) {
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.warning-description) {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.modal-content) {
|
||||
max-width: 30vw;
|
||||
max-height: 75vh;
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
/* Ensure knowledge modals have proper z-index */
|
||||
.knowledge-content :deep(.modal-overlay) {
|
||||
z-index: 1001 !important;
|
||||
position: fixed !important;
|
||||
isolation: isolate;
|
||||
background: rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.modal-overlay),
|
||||
.knowledge-content :deep(.modal-content) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.modal-overlay) {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.pagination) {
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.pagination-button) {
|
||||
width: 100%;
|
||||
font-size: 0.7rem;
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.page-info) {
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Link modal overrides */
|
||||
.knowledge-content :deep(.link-modal) {
|
||||
max-width: 65vw;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-header),
|
||||
.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-row) {
|
||||
grid-template-columns: 2fr 0fr 100px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.org-knowledge-grid .type-cell) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.source-cell) {
|
||||
padding-left: var(--space-sm);
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.action-cell) {
|
||||
padding-right: var(--space-sm);
|
||||
text-align: right;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.link-button),
|
||||
.knowledge-content :deep(.unlink-button) {
|
||||
min-width: 70px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.7rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.org-knowledge-grid) .action-cell {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.org-knowledge-grid) .link-button,
|
||||
.knowledge-content :deep(.org-knowledge-grid) .unlink-button {
|
||||
display: inline-block !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.knowledge-content :deep(.knowledge-grid-container) {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.header-actions) {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.action-button) {
|
||||
font-size: 0.65rem;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-header),
|
||||
.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-row) {
|
||||
grid-template-columns: 2fr 0fr 100px !important;
|
||||
}
|
||||
|
||||
.knowledge-content :deep(.org-knowledge-grid) .action-cell {
|
||||
display: block !important;
|
||||
grid-column: 3 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface LandingPageNodeData {
|
||||
landing_page_heading: string
|
||||
landing_page_content: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: LandingPageNodeData
|
||||
validationErrors: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:model-value', value: LandingPageNodeData): void
|
||||
(e: 'validate-field', field: string): void
|
||||
}>()
|
||||
|
||||
const formData = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: LandingPageNodeData) => emit('update:model-value', value)
|
||||
})
|
||||
|
||||
// Update form data
|
||||
const updateFormData = (field: keyof LandingPageNodeData, value: any) => {
|
||||
formData.value = {
|
||||
...formData.value,
|
||||
[field]: value
|
||||
}
|
||||
|
||||
emit('validate-field', field)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="landing-page-node-config">
|
||||
<div class="form-group">
|
||||
<label for="landing-page-heading">标题 *</label>
|
||||
<input
|
||||
id="landing-page-heading"
|
||||
:value="formData.landing_page_heading"
|
||||
@input="updateFormData('landing_page_heading', ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('validate-field', 'landing_page_heading')"
|
||||
class="form-input"
|
||||
:class="{ 'error': validationErrors.landing_page_heading }"
|
||||
placeholder="输入落地页的欢迎标题"
|
||||
required
|
||||
/>
|
||||
<div v-if="validationErrors.landing_page_heading" class="error-message">
|
||||
{{ validationErrors.landing_page_heading }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="landing-page-content">内容 *</label>
|
||||
<textarea
|
||||
id="landing-page-content"
|
||||
:value="formData.landing_page_content"
|
||||
@input="updateFormData('landing_page_content', ($event.target as HTMLTextAreaElement).value)"
|
||||
@blur="$emit('validate-field', 'landing_page_content')"
|
||||
class="form-textarea"
|
||||
:class="{ 'error': validationErrors.landing_page_content }"
|
||||
placeholder="输入落地页内容,将显示在标题下方。"
|
||||
rows="4"
|
||||
required
|
||||
></textarea>
|
||||
<div v-if="validationErrors.landing_page_content" class="error-message">
|
||||
{{ validationErrors.landing_page_content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.landing-page-node-config {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
|
||||
}
|
||||
|
||||
.form-input.error,
|
||||
.form-textarea.error {
|
||||
border-color: var(--error-color);
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.form-input.error:focus,
|
||||
.form-textarea.error:focus {
|
||||
border-color: var(--error-color);
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.error-message::before {
|
||||
content: "⚠";
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import type { Node } from '@vue-flow/core'
|
||||
|
||||
defineProps<{
|
||||
data: Node['data']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-node">
|
||||
<Handle
|
||||
id="input"
|
||||
type="target"
|
||||
:position="Position.Left"
|
||||
class="node-handle"
|
||||
/>
|
||||
|
||||
<div class="node-content">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">💬</div>
|
||||
<div class="node-title">{{ data.label || '消息' }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.description" class="node-description">
|
||||
{{ data.description }}
|
||||
</div>
|
||||
|
||||
<div v-if="data.config?.message" class="node-preview">
|
||||
<div class="preview-label">消息:</div>
|
||||
<div class="preview-content">
|
||||
{{ data.config.message.substring(0, 100) }}{{ data.config.message.length > 100 ? '...' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
id="output"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="node-handle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-node {
|
||||
position: relative;
|
||||
background: var(--background-color);
|
||||
border: 2px solid #3B82F6;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.message-node:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.node-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-preview {
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-handle {
|
||||
background: #3B82F6;
|
||||
border: 2px solid var(--background-color);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.node-handle:hover {
|
||||
background: #2563EB;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface MessageNodeData {
|
||||
message_text: string
|
||||
show_typing: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: MessageNodeData
|
||||
validationErrors: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:model-value', value: MessageNodeData): void
|
||||
(e: 'validate-field', field: string): void
|
||||
}>()
|
||||
|
||||
const formData = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: MessageNodeData) => emit('update:model-value', value)
|
||||
})
|
||||
|
||||
// Update form data
|
||||
const updateFormData = (field: keyof MessageNodeData, value: any) => {
|
||||
formData.value = {
|
||||
...formData.value,
|
||||
[field]: value
|
||||
}
|
||||
|
||||
emit('validate-field', field)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-node-config">
|
||||
<div class="form-group">
|
||||
<label for="message-text">消息内容 *</label>
|
||||
<textarea
|
||||
id="message-text"
|
||||
:value="formData.message_text"
|
||||
@input="updateFormData('message_text', ($event.target as HTMLTextAreaElement).value)"
|
||||
@blur="$emit('validate-field', 'message_text')"
|
||||
class="form-textarea"
|
||||
:class="{ 'error': validationErrors.message_text }"
|
||||
placeholder="输入要发送给用户的消息"
|
||||
rows="4"
|
||||
required
|
||||
></textarea>
|
||||
<div v-if="validationErrors.message_text" class="error-message">
|
||||
{{ validationErrors.message_text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="formData.show_typing"
|
||||
@change="updateFormData('show_typing', ($event.target as HTMLInputElement).checked)"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
<span>显示输入中提示</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-node-config {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
transition: border-color 0.2s ease;
|
||||
resize: vertical;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
|
||||
}
|
||||
|
||||
.form-textarea.error {
|
||||
border-color: var(--error-color);
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.form-textarea.error:focus {
|
||||
border-color: var(--error-color);
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: normal;
|
||||
margin-bottom: 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox-label span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color);
|
||||
line-height: 1.4;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.error-message::before {
|
||||
content: "⚠";
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,223 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface UserInputNodeData {
|
||||
prompt_message: string
|
||||
confirmation_message: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: UserInputNodeData
|
||||
validationErrors: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: UserInputNodeData): void
|
||||
(e: 'validateField', field: string): void
|
||||
}>()
|
||||
|
||||
// Computed properties for two-way binding
|
||||
const promptMessage = computed({
|
||||
get: () => props.modelValue.prompt_message,
|
||||
set: (value: string) => {
|
||||
emit('update:modelValue', { ...props.modelValue, prompt_message: value })
|
||||
emit('validateField', 'prompt_message')
|
||||
}
|
||||
})
|
||||
|
||||
const confirmationMessage = computed({
|
||||
get: () => props.modelValue.confirmation_message,
|
||||
set: (value: string) => {
|
||||
emit('update:modelValue', { ...props.modelValue, confirmation_message: value })
|
||||
emit('validateField', 'confirmation_message')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-input-config">
|
||||
<div class="form-group">
|
||||
<label for="prompt-message">提示消息</label>
|
||||
<textarea
|
||||
id="prompt-message"
|
||||
v-model="promptMessage"
|
||||
class="form-textarea"
|
||||
:class="{ 'error': validationErrors.prompt_message }"
|
||||
placeholder="输入提示用户填写内容的消息(可选)"
|
||||
rows="3"
|
||||
@blur="$emit('validateField', 'prompt_message')"
|
||||
@input="$emit('validateField', 'prompt_message')"
|
||||
></textarea>
|
||||
<div v-if="validationErrors.prompt_message" class="error-message">
|
||||
{{ validationErrors.prompt_message }}
|
||||
</div>
|
||||
<div class="help-text">
|
||||
用于提示用户填写内容的消息,可选。留空则不显示消息。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmation-message">确认消息</label>
|
||||
<textarea
|
||||
id="confirmation-message"
|
||||
v-model="confirmationMessage"
|
||||
class="form-textarea"
|
||||
placeholder="用户提交内容后显示的消息(可选,例如“感谢您的填写!”)"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="help-text">
|
||||
用户提交内容后显示的消息,可选。留空则直接进入下一个节点。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<div class="info-header">
|
||||
<svg class="info-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M9,9h0a3,3,0,0,1,6,0c0,2-3,3-3,3"></path>
|
||||
<path d="M12,17h0"></path>
|
||||
</svg>
|
||||
<span>用户输入节点行为</span>
|
||||
</div>
|
||||
<div class="info-content">
|
||||
<ul>
|
||||
<li>工作流会在此节点暂停,等待用户输入</li>
|
||||
<li>提示消息会展示给用户</li>
|
||||
<li>用户输入后,工作流继续执行下一个节点</li>
|
||||
<li>后续节点可通过变量引用用户输入</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-input-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
transition: border-color 0.2s ease;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
|
||||
}
|
||||
|
||||
.form-textarea.error {
|
||||
border-color: var(--error-color);
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.form-textarea.error:focus {
|
||||
border-color: var(--error-color);
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.error-message::before {
|
||||
content: "⚠";
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
margin-top: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.info-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-bottom: var(--space-xs);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--primary-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.info-content ul {
|
||||
margin: 0;
|
||||
padding-left: var(--space-md);
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.info-content li {
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.info-content li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import type { Node } from '@vue-flow/core'
|
||||
|
||||
defineProps<{
|
||||
data: Node['data']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wait-node">
|
||||
<Handle
|
||||
id="input"
|
||||
type="target"
|
||||
:position="Position.Left"
|
||||
class="node-handle"
|
||||
/>
|
||||
|
||||
<div class="node-content">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">⏱️</div>
|
||||
<div class="node-title">{{ data.label || '等待' }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.description" class="node-description">
|
||||
{{ data.description }}
|
||||
</div>
|
||||
|
||||
<div v-if="data.config?.duration" class="node-preview">
|
||||
<div class="preview-label">时长:</div>
|
||||
<div class="preview-content">{{ data.config.duration }} {{ data.config.unit || '秒' }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.config?.waitType" class="node-preview">
|
||||
<div class="preview-label">等待类型:</div>
|
||||
<div class="preview-content">{{ data.config.waitType }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
id="output"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="node-handle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wait-node {
|
||||
position: relative;
|
||||
background: var(--background-color);
|
||||
border: 2px solid #6B7280;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.wait-node:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.node-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-preview {
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-handle {
|
||||
background: #6B7280;
|
||||
border: 2px solid var(--background-color);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.node-handle:hover {
|
||||
background: #4B5563;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface WaitNodeData {
|
||||
wait_duration: number
|
||||
wait_unit: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: WaitNodeData
|
||||
validationErrors: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:model-value', value: WaitNodeData): void
|
||||
(e: 'validate-field', field: string): void
|
||||
}>()
|
||||
|
||||
const formData = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: WaitNodeData) => emit('update:model-value', value)
|
||||
})
|
||||
|
||||
// Update form data
|
||||
const updateFormData = (field: keyof WaitNodeData, value: any) => {
|
||||
formData.value = {
|
||||
...formData.value,
|
||||
[field]: value
|
||||
}
|
||||
|
||||
emit('validate-field', field)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wait-node-config">
|
||||
<div class="form-group">
|
||||
<label for="wait-duration">时长 *</label>
|
||||
<input
|
||||
id="wait-duration"
|
||||
:value="formData.wait_duration"
|
||||
@input="updateFormData('wait_duration', parseInt(($event.target as HTMLInputElement).value) || 1)"
|
||||
@blur="$emit('validate-field', 'wait_duration')"
|
||||
type="number"
|
||||
class="form-input"
|
||||
:class="{ 'error': validationErrors.wait_duration }"
|
||||
min="1"
|
||||
placeholder="5"
|
||||
required
|
||||
/>
|
||||
<div v-if="validationErrors.wait_duration" class="error-message">
|
||||
{{ validationErrors.wait_duration }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="wait-unit">时间单位 *</label>
|
||||
<select
|
||||
id="wait-unit"
|
||||
:value="formData.wait_unit"
|
||||
@change="updateFormData('wait_unit', ($event.target as HTMLSelectElement).value)"
|
||||
@blur="$emit('validate-field', 'wait_unit')"
|
||||
class="form-select"
|
||||
:class="{ 'error': validationErrors.wait_unit }"
|
||||
required
|
||||
>
|
||||
<option value="seconds">秒</option>
|
||||
<option value="minutes">分钟</option>
|
||||
<option value="hours">小时</option>
|
||||
</select>
|
||||
<div v-if="validationErrors.wait_unit" class="error-message">
|
||||
{{ validationErrors.wait_unit }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wait-node-config {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
|
||||
}
|
||||
|
||||
.form-input.error,
|
||||
.form-select.error {
|
||||
border-color: var(--error-color);
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.form-input.error:focus,
|
||||
.form-select.error:focus {
|
||||
border-color: var(--error-color);
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.error-message::before {
|
||||
content: "⚠";
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { App } from 'vue'
|
||||
// TODO: Import node editors when they are created
|
||||
// import MessageNodeEditor from './MessageNodeEditor.vue'
|
||||
// import LlmNodeEditor from './LlmNodeEditor.vue'
|
||||
|
||||
export default {
|
||||
install(app: App) {
|
||||
// TODO: Register node editors when they are created
|
||||
// app.component('message-node-editor', MessageNodeEditor)
|
||||
// app.component('llm-node-editor', LlmNodeEditor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { faqService } from '@/services/faq'
|
||||
import { knowledgeService } from '@/services/knowledge'
|
||||
import type { FaqGenerationJob, FaqImportMode, FaqItem, FaqStatus, GenerateEstimate, HelpCenterSettings } from '@/types/faq'
|
||||
|
||||
export type WorkspacePhase = 'loading' | 'empty' | 'generating' | 'populated'
|
||||
|
||||
// Fast tick while a job is running; relaxed tick while a domain/SSL state is
|
||||
// pending. Idle = no requests at all.
|
||||
const ACTIVE_POLL_MS = 3000
|
||||
const IDLE_POLL_MS = 10000
|
||||
|
||||
export function useFaqWorkspace(organizationId: () => string | undefined) {
|
||||
const faqs = ref<FaqItem[]>([])
|
||||
const job = ref<FaqGenerationJob | null>(null)
|
||||
const settings = ref<HelpCenterSettings | null>(null)
|
||||
const estimate = ref<GenerateEstimate | null>(null)
|
||||
const sourceCount = ref(0)
|
||||
const pageCount = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const loadedOnce = ref(false)
|
||||
|
||||
// Multi-select state for bulk publish/unpublish/delete.
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
const selectionActive = computed(() => selectedIds.value.size > 0)
|
||||
|
||||
function toggleSelect(id: string): void {
|
||||
const next = new Set(selectedIds.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
function setSelected(items: FaqItem[], on: boolean): void {
|
||||
const next = new Set(selectedIds.value)
|
||||
for (const item of items) {
|
||||
if (on) next.add(item.id)
|
||||
else next.delete(item.id)
|
||||
}
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
function clearSelection(): void {
|
||||
selectedIds.value = new Set()
|
||||
}
|
||||
|
||||
async function bulkSetStatus(status: FaqStatus): Promise<void> {
|
||||
const ids = [...selectedIds.value]
|
||||
if (!ids.length) return
|
||||
try {
|
||||
const updated = await faqService.setStatus(ids, status)
|
||||
faqs.value = faqs.value.map((f) => (selectedIds.value.has(f.id) ? { ...f, status } : f))
|
||||
clearSelection()
|
||||
toast.success(`${updated} 条 FAQ ${status === 'published' ? '已发布' : '已移至草稿'}`)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
// A batch may have partially applied server-side (>200 selection splits
|
||||
// into requests) — refetch so the list matches the backend.
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDelete(): Promise<void> {
|
||||
const ids = [...selectedIds.value]
|
||||
if (!ids.length) return
|
||||
try {
|
||||
const deleted = await faqService.bulkDelete(ids)
|
||||
faqs.value = faqs.value.filter((f) => !selectedIds.value.has(f.id))
|
||||
clearSelection()
|
||||
toast.success(`${deleted} 条 FAQ 已删除`)
|
||||
// Deleting a source's FAQs makes it eligible for generation again — the
|
||||
// Generate button's new-source count must not stay stale/disabled.
|
||||
void fetchEstimate()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
await refresh() // partial batch may have deleted server-side; resync
|
||||
}
|
||||
}
|
||||
|
||||
// Inline edit state (one card at a time).
|
||||
const editingId = ref<string | null>(null)
|
||||
const isNewFaq = ref(false)
|
||||
const draftQuestion = ref('')
|
||||
const draftAnswer = ref('')
|
||||
// Topic (stored as the FAQ's free-form category). Empty on create means the
|
||||
// server default; empty on edit means "keep the current topic".
|
||||
const draftCategory = ref('')
|
||||
// Per-article SEO overrides. Empty means "derive it" — the server normalizes
|
||||
// a hand-typed slug and treats blanks as cleared, so no client-side slugify.
|
||||
const draftSlug = ref('')
|
||||
const draftUrlPath = ref('')
|
||||
const draftMetaTitle = ref('')
|
||||
const draftMetaDescription = ref('')
|
||||
const isSaving = ref(false)
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let lastPollAt = 0
|
||||
|
||||
const isJobActive = computed(
|
||||
() => job.value?.status === 'pending' || job.value?.status === 'processing',
|
||||
)
|
||||
|
||||
const phase = computed<WorkspacePhase>(() => {
|
||||
if (isLoading.value && !loadedOnce.value) return 'loading'
|
||||
// Regenerating with existing FAQs keeps the list visible — only the
|
||||
// generate bar flips; the full progress card is for first-run only.
|
||||
if (isJobActive.value && faqs.value.length === 0) return 'generating'
|
||||
return faqs.value.length > 0 ? 'populated' : 'empty'
|
||||
})
|
||||
|
||||
const barPhase = computed<'idle' | 'generating' | 'ready'>(() => {
|
||||
if (isJobActive.value) return 'generating'
|
||||
return faqs.value.length > 0 ? 'ready' : 'idle'
|
||||
})
|
||||
|
||||
const publishedCount = computed(() => faqs.value.filter((f) => f.status === 'published').length)
|
||||
|
||||
// ---- search + filter + collapse (all client-side over the loaded set) ----
|
||||
const searchQuery = ref('')
|
||||
const categoryFilter = ref<string | null>(null)
|
||||
const statusFilter = ref<'all' | FaqStatus>('all')
|
||||
const collapsedCategories = ref<Set<string>>(new Set())
|
||||
|
||||
const categories = computed(() =>
|
||||
[...new Set(faqs.value.map((f) => f.category))].sort((a, b) => a.localeCompare(b)),
|
||||
)
|
||||
|
||||
const hasActiveFilters = computed(
|
||||
() =>
|
||||
searchQuery.value.trim() !== '' ||
|
||||
categoryFilter.value !== null ||
|
||||
statusFilter.value !== 'all',
|
||||
)
|
||||
|
||||
const filteredFaqs = computed(() => {
|
||||
// Tokenized: every term must appear somewhere (question/answer/category),
|
||||
// so "close my" matches "How to close my account".
|
||||
const terms = searchQuery.value.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||
return faqs.value.filter((f) => {
|
||||
if (categoryFilter.value && f.category !== categoryFilter.value) return false
|
||||
if (statusFilter.value !== 'all' && f.status !== statusFilter.value) return false
|
||||
if (terms.length) {
|
||||
const haystack = `${f.question} ${f.answer} ${f.category}`.toLowerCase()
|
||||
if (!terms.every((t) => haystack.includes(t))) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const filteredCount = computed(() => filteredFaqs.value.length)
|
||||
|
||||
const groupedFaqs = computed(() => {
|
||||
const groups = new Map<string, FaqItem[]>()
|
||||
for (const faq of filteredFaqs.value) {
|
||||
const list = groups.get(faq.category) || []
|
||||
list.push(faq)
|
||||
groups.set(faq.category, list)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function resetFilters(): void {
|
||||
searchQuery.value = ''
|
||||
categoryFilter.value = null
|
||||
statusFilter.value = 'all'
|
||||
}
|
||||
|
||||
function toggleCategory(category: string): void {
|
||||
const next = new Set(collapsedCategories.value)
|
||||
if (next.has(category)) next.delete(category)
|
||||
else next.add(category)
|
||||
collapsedCategories.value = next
|
||||
}
|
||||
|
||||
// While filtering/searching, always show matches regardless of collapse state.
|
||||
function isCategoryOpen(category: string): boolean {
|
||||
return hasActiveFilters.value || !collapsedCategories.value.has(category)
|
||||
}
|
||||
|
||||
const domainPending = computed(() => {
|
||||
const domain = settings.value?.domain
|
||||
if (!domain?.custom_domain) return false
|
||||
return domain.domain_status !== 'verified' || domain.ssl_status === 'pending'
|
||||
})
|
||||
|
||||
// Bounded safety cap; a curated FAQ list should never approach this.
|
||||
const MAX_FAQ_PAGES = 10
|
||||
|
||||
async function fetchFaqs(): Promise<void> {
|
||||
const all: FaqItem[] = []
|
||||
let page = 1
|
||||
for (;;) {
|
||||
const response = await faqService.getFaqs({ page })
|
||||
all.push(...response.faqs)
|
||||
if (page >= response.pagination.total_pages || page >= MAX_FAQ_PAGES) break
|
||||
page += 1
|
||||
}
|
||||
faqs.value = all
|
||||
}
|
||||
|
||||
async function fetchJob(): Promise<void> {
|
||||
job.value = await faqService.getJob(true)
|
||||
}
|
||||
|
||||
async function fetchSettings(): Promise<void> {
|
||||
settings.value = await faqService.getSettings()
|
||||
}
|
||||
|
||||
async function fetchEstimate(includePages = false): Promise<void> {
|
||||
// Background fetches (page load, deletes, job completion) skip the
|
||||
// per-source page scan — the button label only needs new_sources. The
|
||||
// confirm dialog passes includePages=true for the full call estimate.
|
||||
// Non-fatal: locked plans 403 here; the generate button then just says
|
||||
// "Generate" without the new-source count.
|
||||
try {
|
||||
estimate.value = await faqService.getGenerateEstimate(includePages)
|
||||
} catch {
|
||||
estimate.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCounts(): Promise<void> {
|
||||
const orgId = organizationId()
|
||||
if (!orgId) return
|
||||
const response = await knowledgeService.getKnowledgeByOrganization(orgId, 1, 100)
|
||||
const items = response.knowledge || []
|
||||
sourceCount.value = response.pagination?.total ?? response.pagination?.total_count ?? items.length
|
||||
pageCount.value = items.reduce(
|
||||
(sum: number, item: { pages?: unknown[] }) => sum + (item.pages?.length || 1),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
isLoading.value = true
|
||||
clearSelection()
|
||||
try {
|
||||
await Promise.all([fetchFaqs(), fetchJob(), fetchSettings(), fetchCounts(), fetchEstimate()])
|
||||
loadedOnce.value = true
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pollTick(): Promise<void> {
|
||||
const interval = isJobActive.value ? ACTIVE_POLL_MS : IDLE_POLL_MS
|
||||
if (!isJobActive.value && !domainPending.value) return
|
||||
const now = Date.now()
|
||||
if (now - lastPollAt < interval - 100) return
|
||||
lastPollAt = now
|
||||
try {
|
||||
if (isJobActive.value) {
|
||||
const wasActive = job.value?.id
|
||||
await fetchJob()
|
||||
const stillActive = job.value?.status === 'pending' || job.value?.status === 'processing'
|
||||
if (wasActive && !stillActive) {
|
||||
await Promise.all([fetchFaqs(), fetchSettings(), fetchEstimate()])
|
||||
const finished = await faqService.getJob(false)
|
||||
if (finished?.status === 'failed') {
|
||||
toast.error(finished.error || 'FAQ 生成失败')
|
||||
} else if (finished) {
|
||||
toast.success(
|
||||
finished.faqs_created
|
||||
? `${finished.faqs_created} 条草稿 FAQ 待审核`
|
||||
: '没有发现新的 FAQ,现有 FAQ 已覆盖这些内容',
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (domainPending.value) {
|
||||
await fetchSettings()
|
||||
}
|
||||
} catch {
|
||||
// Transient polling errors are silent; the next tick retries.
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
stopPolling()
|
||||
pollTimer = setInterval(pollTick, ACTIVE_POLL_MS)
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function startGeneration(knowledgeIds?: number[]): Promise<boolean> {
|
||||
try {
|
||||
job.value = await faqService.startGeneration(knowledgeIds)
|
||||
return true
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitImport(
|
||||
url: string,
|
||||
mode: FaqImportMode = 'qa',
|
||||
preserveUrls = false,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
job.value = await faqService.importFaq(url, mode, preserveUrls)
|
||||
toast.success('导入已开始,完成后会生成草稿')
|
||||
return true
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPdfImport(file: File): Promise<boolean> {
|
||||
try {
|
||||
job.value = await faqService.importPdf(file)
|
||||
toast.success('导入已开始,完成后会生成草稿')
|
||||
return true
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePublish(faq: FaqItem): Promise<void> {
|
||||
const nextStatus = faq.status === 'published' ? 'draft' : 'published'
|
||||
const previous = faq.status
|
||||
faq.status = nextStatus // optimistic
|
||||
try {
|
||||
await faqService.setStatus([faq.id], nextStatus)
|
||||
} catch (error: any) {
|
||||
faq.status = previous
|
||||
toast.error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
function setSeoDraft(faq: FaqItem | null): void {
|
||||
draftSlug.value = faq?.slug || ''
|
||||
draftUrlPath.value = faq?.url_path || ''
|
||||
draftMetaTitle.value = faq?.meta_title || ''
|
||||
draftMetaDescription.value = faq?.meta_description || ''
|
||||
}
|
||||
|
||||
function startEdit(faq: FaqItem): void {
|
||||
editingId.value = faq.id
|
||||
isNewFaq.value = false
|
||||
draftQuestion.value = faq.question
|
||||
draftAnswer.value = faq.answer
|
||||
draftCategory.value = faq.category
|
||||
setSeoDraft(faq)
|
||||
}
|
||||
|
||||
function startAdd(): void {
|
||||
editingId.value = 'new'
|
||||
isNewFaq.value = true
|
||||
draftQuestion.value = ''
|
||||
draftAnswer.value = ''
|
||||
draftCategory.value = ''
|
||||
setSeoDraft(null)
|
||||
}
|
||||
|
||||
function cancelEdit(): void {
|
||||
editingId.value = null
|
||||
isNewFaq.value = false
|
||||
draftQuestion.value = ''
|
||||
draftAnswer.value = ''
|
||||
draftCategory.value = ''
|
||||
setSeoDraft(null)
|
||||
}
|
||||
|
||||
async function saveEdit(): Promise<void> {
|
||||
const question = draftQuestion.value.trim()
|
||||
const answer = draftAnswer.value.trim()
|
||||
if (!question || !answer) {
|
||||
toast.error('问题和答案均为必填')
|
||||
return
|
||||
}
|
||||
// Blank SEO fields are sent through so clearing one in the UI clears it
|
||||
// server-side, restoring the derived title/description (and, for the slug,
|
||||
// keeping whatever is already assigned).
|
||||
const seo = {
|
||||
slug: draftSlug.value.trim(),
|
||||
url_path: draftUrlPath.value.trim(),
|
||||
meta_title: draftMetaTitle.value.trim(),
|
||||
meta_description: draftMetaDescription.value.trim(),
|
||||
}
|
||||
// The server rejects a blank category, so only send one when set: on
|
||||
// create a blank falls back to the server default, on edit it keeps the
|
||||
// FAQ's current topic.
|
||||
const category = draftCategory.value.trim()
|
||||
const withCategory = category ? { category } : {}
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (isNewFaq.value) {
|
||||
const created = await faqService.createFaq({ question, answer, ...withCategory, ...seo })
|
||||
faqs.value = [...faqs.value, created]
|
||||
} else if (editingId.value) {
|
||||
const updated = await faqService.updateFaq(editingId.value, { question, answer, ...withCategory, ...seo })
|
||||
faqs.value = faqs.value.map((f) => (f.id === updated.id ? updated : f))
|
||||
}
|
||||
cancelEdit()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFaq(faq: FaqItem): Promise<void> {
|
||||
try {
|
||||
await faqService.deleteFaq(faq.id)
|
||||
faqs.value = faqs.value.filter((f) => f.id !== faq.id)
|
||||
if (editingId.value === faq.id) cancelEdit()
|
||||
// Keep the Generate button's new-source count fresh (see bulkDelete).
|
||||
void fetchEstimate()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
faqs,
|
||||
job,
|
||||
settings,
|
||||
estimate,
|
||||
fetchEstimate,
|
||||
sourceCount,
|
||||
pageCount,
|
||||
isLoading,
|
||||
phase,
|
||||
barPhase,
|
||||
publishedCount,
|
||||
groupedFaqs,
|
||||
searchQuery,
|
||||
categoryFilter,
|
||||
statusFilter,
|
||||
categories,
|
||||
hasActiveFilters,
|
||||
filteredCount,
|
||||
resetFilters,
|
||||
toggleCategory,
|
||||
isCategoryOpen,
|
||||
isJobActive,
|
||||
selectedIds,
|
||||
selectionActive,
|
||||
toggleSelect,
|
||||
setSelected,
|
||||
clearSelection,
|
||||
bulkSetStatus,
|
||||
bulkDelete,
|
||||
editingId,
|
||||
isNewFaq,
|
||||
draftQuestion,
|
||||
draftAnswer,
|
||||
draftCategory,
|
||||
draftSlug,
|
||||
draftUrlPath,
|
||||
draftMetaTitle,
|
||||
draftMetaDescription,
|
||||
isSaving,
|
||||
refresh,
|
||||
fetchSettings,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
pollTick,
|
||||
startGeneration,
|
||||
submitImport,
|
||||
submitPdfImport,
|
||||
togglePublish,
|
||||
startEdit,
|
||||
startAdd,
|
||||
cancelEdit,
|
||||
saveEdit,
|
||||
deleteFaq,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { computed, ref, watch, type Ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { faqService } from '@/services/faq'
|
||||
import type { HelpCenterSettings, HelpCenterSettingsUpdate } from '@/types/faq'
|
||||
|
||||
export type SaveState = 'idle' | 'saving' | 'saved' | 'error'
|
||||
|
||||
const AUTOSAVE_DEBOUNCE_MS = 800
|
||||
const MAX_LOGO_BYTES = 2 * 1024 * 1024
|
||||
const MAX_FAVICON_BYTES = 1 * 1024 * 1024
|
||||
|
||||
/** Appearance/settings editing with debounced autosave — the design has no
|
||||
* Save button ("changes apply instantly to your published help center"). */
|
||||
export function useHelpCenterSettings(settings: Ref<HelpCenterSettings | null>) {
|
||||
const saveState = ref<SaveState>('idle')
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let saveInFlight = false
|
||||
let trailingSave: HelpCenterSettingsUpdate | null = null
|
||||
let suppressWatch = false
|
||||
|
||||
const brandColor = computed(() => settings.value?.brand_color || '#4338CA')
|
||||
|
||||
function applyResponse(updated: HelpCenterSettings): void {
|
||||
suppressWatch = true
|
||||
settings.value = updated
|
||||
// Release after the watcher has seen (and ignored) this assignment.
|
||||
setTimeout(() => {
|
||||
suppressWatch = false
|
||||
}, 0)
|
||||
}
|
||||
|
||||
async function save(payload: HelpCenterSettingsUpdate): Promise<void> {
|
||||
if (saveInFlight) {
|
||||
trailingSave = { ...(trailingSave || {}), ...payload }
|
||||
return
|
||||
}
|
||||
saveInFlight = true
|
||||
saveState.value = 'saving'
|
||||
try {
|
||||
applyResponse(await faqService.updateSettings(payload))
|
||||
saveState.value = 'saved'
|
||||
} catch (error: any) {
|
||||
saveState.value = 'error'
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
saveInFlight = false
|
||||
if (trailingSave) {
|
||||
const queued = trailingSave
|
||||
trailingSave = null
|
||||
void save(queued)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Debounced path for text inputs (links, CTA, labels). */
|
||||
function queueSave(payload: HelpCenterSettingsUpdate): void {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => void save(payload), AUTOSAVE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/** Immediate path for discrete actions (swatch click, toggles, selects). */
|
||||
async function saveNow(payload: HelpCenterSettingsUpdate): Promise<void> {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
await save(payload)
|
||||
}
|
||||
|
||||
// Deep-watch branding fields the inputs mutate in place (header_links rows).
|
||||
watch(
|
||||
() => settings.value?.header_links,
|
||||
(links, previous) => {
|
||||
if (suppressWatch || links === undefined || previous === undefined) return
|
||||
queueSave({ header_links: links })
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
async function uploadLogo(file: File): Promise<void> {
|
||||
if (file.size > MAX_LOGO_BYTES) {
|
||||
toast.error('Logo 不能超过 2 MB')
|
||||
return
|
||||
}
|
||||
saveState.value = 'saving'
|
||||
try {
|
||||
applyResponse(await faqService.uploadLogo(file))
|
||||
saveState.value = 'saved'
|
||||
} catch (error: any) {
|
||||
saveState.value = 'error'
|
||||
toast.error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLogo(): Promise<void> {
|
||||
saveState.value = 'saving'
|
||||
try {
|
||||
applyResponse(await faqService.removeLogo())
|
||||
saveState.value = 'saved'
|
||||
} catch (error: any) {
|
||||
saveState.value = 'error'
|
||||
toast.error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFavicon(file: File): Promise<void> {
|
||||
if (file.size > MAX_FAVICON_BYTES) {
|
||||
toast.error('网站图标不能超过 1 MB')
|
||||
return
|
||||
}
|
||||
saveState.value = 'saving'
|
||||
try {
|
||||
applyResponse(await faqService.uploadFavicon(file))
|
||||
saveState.value = 'saved'
|
||||
} catch (error: any) {
|
||||
saveState.value = 'error'
|
||||
toast.error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFavicon(): Promise<void> {
|
||||
saveState.value = 'saving'
|
||||
try {
|
||||
applyResponse(await faqService.removeFavicon())
|
||||
saveState.value = 'saved'
|
||||
} catch (error: any) {
|
||||
saveState.value = 'error'
|
||||
toast.error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Domain lifecycle (explicit Verify button — never autosaved).
|
||||
const domainBusy = ref(false)
|
||||
|
||||
async function setDomain(domain: string): Promise<void> {
|
||||
domainBusy.value = true
|
||||
try {
|
||||
const result = await faqService.setDomain(domain)
|
||||
if (settings.value) settings.value = { ...settings.value, domain: result }
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
domainBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyDomain(): Promise<void> {
|
||||
domainBusy.value = true
|
||||
try {
|
||||
const result = await faqService.verifyDomain()
|
||||
if (settings.value) settings.value = { ...settings.value, domain: result }
|
||||
if (result.domain_status === 'verified') {
|
||||
toast.success('域名已验证')
|
||||
} else {
|
||||
toast.info('记录尚未生效,DNS 变更可能需要几分钟才能传播')
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
domainBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDomain(): Promise<void> {
|
||||
domainBusy.value = true
|
||||
try {
|
||||
const result = await faqService.removeDomain()
|
||||
if (settings.value) settings.value = { ...settings.value, domain: result }
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
domainBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
saveState,
|
||||
brandColor,
|
||||
queueSave,
|
||||
saveNow,
|
||||
uploadLogo,
|
||||
removeLogo,
|
||||
uploadFavicon,
|
||||
removeFavicon,
|
||||
domainBusy,
|
||||
setDomain,
|
||||
verifyDomain,
|
||||
removeDomain,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
checkJiraConnection,
|
||||
getJiraProjects,
|
||||
getJiraIssueTypes,
|
||||
saveAgentJiraConfig,
|
||||
getAgentJiraConfig
|
||||
} from '@/services/jira'
|
||||
|
||||
export function useJiraIntegration(agentId: string) {
|
||||
// Jira integration state
|
||||
const jiraConnected = ref(false)
|
||||
const jiraLoading = ref(false)
|
||||
const createTicketEnabled = ref(false)
|
||||
const jiraProjects = ref<any[]>([])
|
||||
const jiraIssueTypes = ref<any[]>([])
|
||||
const selectedProject = ref<string>('')
|
||||
const selectedIssueType = ref<string>('')
|
||||
const loadingProjects = ref(false)
|
||||
const loadingIssueTypes = ref(false)
|
||||
|
||||
/**
|
||||
* Check if Jira is connected
|
||||
*/
|
||||
const checkJiraStatus = async () => {
|
||||
try {
|
||||
jiraLoading.value = true
|
||||
const status = await checkJiraConnection()
|
||||
jiraConnected.value = status.connected
|
||||
|
||||
// If Jira is connected, fetch projects
|
||||
if (jiraConnected.value && createTicketEnabled.value) {
|
||||
await fetchJiraProjects()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check Jira status:', error)
|
||||
} finally {
|
||||
jiraLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Jira projects
|
||||
*/
|
||||
const fetchJiraProjects = async () => {
|
||||
if (!jiraConnected.value) return
|
||||
|
||||
try {
|
||||
loadingProjects.value = true
|
||||
const projects = await getJiraProjects()
|
||||
jiraProjects.value = projects
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Jira projects:', error)
|
||||
toast.error('加载 Jira 项目失败')
|
||||
} finally {
|
||||
loadingProjects.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Jira issue types for a project
|
||||
*/
|
||||
const fetchJiraIssueTypes = async (projectKey: string) => {
|
||||
if (!projectKey) {
|
||||
jiraIssueTypes.value = []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loadingIssueTypes.value = true
|
||||
const issueTypes = await getJiraIssueTypes(projectKey)
|
||||
jiraIssueTypes.value = issueTypes
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Jira issue types:', error)
|
||||
toast.error('加载 Jira 问题类型失败')
|
||||
} finally {
|
||||
loadingIssueTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle Jira ticket creation
|
||||
*/
|
||||
const toggleCreateTicket = async () => {
|
||||
// If enabling and Jira is not connected, show error
|
||||
if (!createTicketEnabled.value && !jiraConnected.value) {
|
||||
toast.error('无法开启工单创建:Jira 未连接', {
|
||||
description: '请先在集成设置中连接 Jira。',
|
||||
duration: 5000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Toggle the create ticket setting
|
||||
const newValue = !createTicketEnabled.value
|
||||
|
||||
// If disabling, just save the config with enabled=false
|
||||
if (!newValue) {
|
||||
await saveAgentJiraConfig(agentId, { enabled: false })
|
||||
createTicketEnabled.value = false
|
||||
toast.success('工单创建已关闭')
|
||||
return
|
||||
}
|
||||
|
||||
// If enabling but no project/issue type selected yet, just update the UI state
|
||||
// The actual save will happen when the user selects a project and issue type
|
||||
if (newValue && (!selectedProject.value || !selectedIssueType.value)) {
|
||||
createTicketEnabled.value = true
|
||||
// Fetch projects if not already loaded
|
||||
if (jiraProjects.value.length === 0) {
|
||||
await fetchJiraProjects()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If enabling and project/issue type are selected, save the config
|
||||
await saveAgentJiraConfig(agentId, {
|
||||
enabled: true,
|
||||
projectKey: selectedProject.value,
|
||||
issueTypeId: selectedIssueType.value
|
||||
})
|
||||
|
||||
createTicketEnabled.value = true
|
||||
toast.success('工单创建已开启')
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle ticket creation:', error)
|
||||
toast.error('更新工单创建设置失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Jira configuration
|
||||
*/
|
||||
const saveJiraConfig = async (projectKey?: string, issueTypeId?: string) => {
|
||||
// Use provided values or fall back to the state values
|
||||
const projectToUse = projectKey || selectedProject.value
|
||||
const issueTypeToUse = issueTypeId || selectedIssueType.value
|
||||
|
||||
if (!projectToUse || !issueTypeToUse) {
|
||||
toast.error('请选择项目和问题类型')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await saveAgentJiraConfig(agentId, {
|
||||
enabled: true,
|
||||
projectKey: projectToUse,
|
||||
issueTypeId: issueTypeToUse
|
||||
})
|
||||
|
||||
// Update the local state to match what was saved
|
||||
selectedProject.value = projectToUse
|
||||
selectedIssueType.value = issueTypeToUse
|
||||
|
||||
toast.success('Jira 配置已保存')
|
||||
} catch (error) {
|
||||
console.error('Failed to save Jira config:', error)
|
||||
toast.error('保存 Jira 配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch agent's Jira configuration
|
||||
*/
|
||||
const fetchAgentJiraConfig = async () => {
|
||||
try {
|
||||
const config = await getAgentJiraConfig(agentId)
|
||||
createTicketEnabled.value = config.enabled
|
||||
selectedProject.value = config.projectKey || ''
|
||||
selectedIssueType.value = config.issueTypeId || ''
|
||||
|
||||
// If there's an existing configuration, fetch the projects and issue types
|
||||
if (config.enabled && config.projectKey) {
|
||||
// First check if Jira is connected
|
||||
const status = await checkJiraConnection()
|
||||
jiraConnected.value = status.connected
|
||||
|
||||
if (jiraConnected.value) {
|
||||
// Fetch projects
|
||||
loadingProjects.value = true
|
||||
try {
|
||||
const projects = await getJiraProjects()
|
||||
jiraProjects.value = projects
|
||||
|
||||
// If project is selected, fetch issue types
|
||||
if (selectedProject.value) {
|
||||
await fetchJiraIssueTypes(selectedProject.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Jira projects:', error)
|
||||
toast.error('加载 Jira 项目失败')
|
||||
} finally {
|
||||
loadingProjects.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch agent Jira config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle project change
|
||||
*/
|
||||
const handleProjectChange = async (projectKey: string) => {
|
||||
if (projectKey !== selectedProject.value) {
|
||||
selectedProject.value = projectKey
|
||||
selectedIssueType.value = '' // Reset issue type when project changes
|
||||
await fetchJiraIssueTypes(projectKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle issue type change
|
||||
*/
|
||||
const handleIssueTypeChange = (issueTypeId: string) => {
|
||||
selectedIssueType.value = issueTypeId
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
jiraConnected,
|
||||
jiraLoading,
|
||||
createTicketEnabled,
|
||||
jiraProjects,
|
||||
jiraIssueTypes,
|
||||
selectedProject,
|
||||
selectedIssueType,
|
||||
loadingProjects,
|
||||
loadingIssueTypes,
|
||||
|
||||
// Methods
|
||||
checkJiraStatus,
|
||||
fetchJiraProjects,
|
||||
fetchJiraIssueTypes,
|
||||
toggleCreateTicket,
|
||||
saveJiraConfig,
|
||||
fetchAgentJiraConfig,
|
||||
handleProjectChange,
|
||||
handleIssueTypeChange
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
checkJiraConnection,
|
||||
getJiraProjects,
|
||||
getJiraIssueTypes,
|
||||
getJiraPriorities,
|
||||
createJiraTicket,
|
||||
checkPriorityAvailability
|
||||
} from '@/services/jira'
|
||||
|
||||
export function useJiraTicket() {
|
||||
// State
|
||||
const jiraConnected = ref(false)
|
||||
const jiraLoading = ref(false)
|
||||
const showTicketModal = ref(false)
|
||||
const jiraProjects = ref<any[]>([])
|
||||
const jiraIssueTypes = ref<any[]>([])
|
||||
const jiraPriorities = ref<any[]>([])
|
||||
const selectedProject = ref<string>('')
|
||||
const selectedIssueType = ref<string>('')
|
||||
const selectedPriority = ref<string>('')
|
||||
const ticketSummary = ref<string>('')
|
||||
const ticketDescription = ref<string>('')
|
||||
const loadingProjects = ref(false)
|
||||
const loadingIssueTypes = ref(false)
|
||||
const loadingPriorities = ref(false)
|
||||
const creatingTicket = ref(false)
|
||||
const createdTicketKey = ref<string | null>(null)
|
||||
const priorityAvailable = ref(false)
|
||||
const checkingPriorityAvailability = ref(false)
|
||||
|
||||
// Check if Jira is connected
|
||||
const checkJiraStatus = async () => {
|
||||
jiraLoading.value = true
|
||||
try {
|
||||
const response = await checkJiraConnection()
|
||||
jiraConnected.value = response.connected
|
||||
if (jiraConnected.value) {
|
||||
await fetchJiraProjects()
|
||||
await fetchJiraPriorities()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Jira status:', error)
|
||||
jiraConnected.value = false
|
||||
} finally {
|
||||
jiraLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Jira projects
|
||||
const fetchJiraProjects = async () => {
|
||||
if (!jiraConnected.value) return
|
||||
|
||||
loadingProjects.value = true
|
||||
try {
|
||||
const projects = await getJiraProjects()
|
||||
jiraProjects.value = projects
|
||||
} catch (error) {
|
||||
console.error('Error fetching Jira projects:', error)
|
||||
toast.error('加载 Jira 项目失败')
|
||||
} finally {
|
||||
loadingProjects.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Jira issue types for a project
|
||||
const fetchJiraIssueTypes = async (projectKey: string) => {
|
||||
if (!projectKey) return
|
||||
|
||||
loadingIssueTypes.value = true
|
||||
try {
|
||||
const issueTypes = await getJiraIssueTypes(projectKey)
|
||||
jiraIssueTypes.value = issueTypes
|
||||
} catch (error) {
|
||||
console.error('Error fetching Jira issue types:', error)
|
||||
toast.error('加载问题类型失败')
|
||||
} finally {
|
||||
loadingIssueTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Jira priorities
|
||||
const fetchJiraPriorities = async () => {
|
||||
if (!jiraConnected.value) return
|
||||
|
||||
loadingPriorities.value = true
|
||||
try {
|
||||
const priorities = await getJiraPriorities()
|
||||
jiraPriorities.value = priorities
|
||||
} catch (error) {
|
||||
console.error('Error fetching Jira priorities:', error)
|
||||
toast.error('加载优先级失败')
|
||||
} finally {
|
||||
loadingPriorities.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle project change
|
||||
const handleProjectChange = async (projectKey: string) => {
|
||||
selectedProject.value = projectKey
|
||||
selectedIssueType.value = ''
|
||||
selectedPriority.value = ''
|
||||
priorityAvailable.value = false
|
||||
|
||||
if (projectKey) {
|
||||
await fetchJiraIssueTypes(projectKey)
|
||||
} else {
|
||||
jiraIssueTypes.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// Handle issue type change
|
||||
const handleIssueTypeChange = async (issueTypeId: string) => {
|
||||
selectedIssueType.value = issueTypeId
|
||||
selectedPriority.value = ''
|
||||
|
||||
if (selectedProject.value && issueTypeId) {
|
||||
await checkPriorityField(selectedProject.value, issueTypeId)
|
||||
} else {
|
||||
priorityAvailable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Check if priority field is available
|
||||
const checkPriorityField = async (projectKey: string, issueTypeId: string) => {
|
||||
checkingPriorityAvailability.value = true
|
||||
try {
|
||||
priorityAvailable.value = await checkPriorityAvailability(projectKey, issueTypeId)
|
||||
|
||||
// If priority is available, fetch priorities
|
||||
if (priorityAvailable.value) {
|
||||
await fetchJiraPriorities()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking priority availability:', error)
|
||||
priorityAvailable.value = false
|
||||
} finally {
|
||||
checkingPriorityAvailability.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Open ticket modal
|
||||
const openTicketModal = async (chatSummary?: string) => {
|
||||
// Reset form
|
||||
selectedProject.value = ''
|
||||
selectedIssueType.value = ''
|
||||
selectedPriority.value = ''
|
||||
ticketDescription.value = ''
|
||||
createdTicketKey.value = null
|
||||
|
||||
// Set summary from chat if provided
|
||||
ticketSummary.value = chatSummary || ''
|
||||
|
||||
// Check Jira status and load data
|
||||
await checkJiraStatus()
|
||||
|
||||
// Show modal
|
||||
showTicketModal.value = true
|
||||
}
|
||||
|
||||
// Close ticket modal
|
||||
const closeTicketModal = () => {
|
||||
showTicketModal.value = false
|
||||
}
|
||||
|
||||
// Create Jira ticket
|
||||
const submitTicket = async (chatId?: string) => {
|
||||
if (!selectedProject.value || !selectedIssueType.value || !ticketSummary.value) {
|
||||
toast.error('请填写所有必填项')
|
||||
return
|
||||
}
|
||||
|
||||
creatingTicket.value = true
|
||||
try {
|
||||
const response = await createJiraTicket({
|
||||
projectKey: selectedProject.value,
|
||||
issueTypeId: selectedIssueType.value,
|
||||
summary: ticketSummary.value,
|
||||
description: ticketDescription.value,
|
||||
priority: selectedPriority.value,
|
||||
chatId
|
||||
})
|
||||
|
||||
createdTicketKey.value = response.key
|
||||
toast.success(`工单 ${response.key} 已创建`)
|
||||
return response.key
|
||||
} catch (error) {
|
||||
console.error('Error creating ticket:', error)
|
||||
toast.error('创建工单失败')
|
||||
return null
|
||||
} finally {
|
||||
creatingTicket.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Check if form is valid
|
||||
const isFormValid = computed(() => {
|
||||
return selectedProject.value &&
|
||||
selectedIssueType.value &&
|
||||
ticketSummary.value.trim().length > 0
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
jiraConnected,
|
||||
jiraLoading,
|
||||
showTicketModal,
|
||||
jiraProjects,
|
||||
jiraIssueTypes,
|
||||
jiraPriorities,
|
||||
selectedProject,
|
||||
selectedIssueType,
|
||||
selectedPriority,
|
||||
ticketSummary,
|
||||
ticketDescription,
|
||||
loadingProjects,
|
||||
loadingIssueTypes,
|
||||
loadingPriorities,
|
||||
creatingTicket,
|
||||
createdTicketKey,
|
||||
priorityAvailable,
|
||||
checkingPriorityAvailability,
|
||||
isFormValid,
|
||||
|
||||
// Methods
|
||||
checkJiraStatus,
|
||||
fetchJiraProjects,
|
||||
fetchJiraIssueTypes,
|
||||
fetchJiraPriorities,
|
||||
handleProjectChange,
|
||||
handleIssueTypeChange,
|
||||
checkPriorityField,
|
||||
openTicketModal,
|
||||
closeTicketModal,
|
||||
submitTicket
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ref, reactive } from 'vue'
|
||||
import { mcpService } from '@/services/mcp'
|
||||
import type { MCPTool, MCPToolCreate, MCPToolReferences, MCPToolTestResult, MCPTransportType } from '@/types/mcp'
|
||||
import { DEFAULT_MCP_TIMEOUT, clampMCPTimeout } from '@/utils/mcp'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
export function useMCPTools(agentId: string) {
|
||||
// State
|
||||
const agentMCPTools = ref<MCPTool[]>([])
|
||||
const availableMCPTools = ref<MCPTool[]>([])
|
||||
const isLoading = ref(false)
|
||||
const isLoadingAvailable = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const showCreateModal = ref(false)
|
||||
const showLinkModal = ref(false)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deleteTargetId = ref<number | null>(null)
|
||||
const deleteReferences = ref<MCPToolReferences | null>(null)
|
||||
// Which tool the form is editing, or null when it is creating one.
|
||||
const editingToolId = ref<number | null>(null)
|
||||
|
||||
// Form state for creating MCP tools
|
||||
const createForm = reactive<MCPToolCreate>({
|
||||
name: '',
|
||||
description: '',
|
||||
usage_guidance: '',
|
||||
transport_type: 'stdio' as MCPTransportType,
|
||||
enabled: true,
|
||||
command: '',
|
||||
args: [],
|
||||
env_vars: {},
|
||||
url: '',
|
||||
headers: {},
|
||||
timeout: DEFAULT_MCP_TIMEOUT,
|
||||
sse_read_timeout: 60,
|
||||
terminate_on_close: true
|
||||
})
|
||||
|
||||
// Transport type options
|
||||
const transportTypes = [
|
||||
{ value: 'stdio', label: 'STDIO', description: '标准输入/输出通信' },
|
||||
{ value: 'sse', label: 'Server-Sent Events', description: 'HTTP 流式通信' },
|
||||
{ value: 'http', label: 'HTTP', description: '请求/响应通信' }
|
||||
]
|
||||
|
||||
// Common MCP tool presets
|
||||
const mcpPresets = [
|
||||
{
|
||||
name: 'File System',
|
||||
description: 'Access and manage files and directories',
|
||||
transport_type: 'stdio' as MCPTransportType,
|
||||
command: 'npx',
|
||||
args: ['-y','@modelcontextprotocol/server-filesystem'],
|
||||
env_vars: { ALLOWED_DIRECTORIES: '/path/to/allowed/directory' }
|
||||
},
|
||||
{
|
||||
name: 'Weather',
|
||||
description: 'Get weather information',
|
||||
transport_type: 'stdio' as MCPTransportType,
|
||||
command: 'uvx',
|
||||
args: ["--from", "git+https://github.com/adhikasp/mcp-weather.git", "mcp-weather"],
|
||||
env_vars: { ACCUWEATHER_API_KEY: 'your-api-key' }
|
||||
}
|
||||
]
|
||||
|
||||
// Fetch agent's MCP tools
|
||||
const fetchAgentMCPTools = async () => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await mcpService.getAgentMCPTools(agentId)
|
||||
agentMCPTools.value = response.mcp_tools
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '加载 MCP 工具失败'
|
||||
console.error('Error fetching agent MCP tools:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch available MCP tools for linking
|
||||
const fetchAvailableMCPTools = async () => {
|
||||
isLoadingAvailable.value = true
|
||||
|
||||
try {
|
||||
const tools = await mcpService.getOrganizationMCPTools(true)
|
||||
availableMCPTools.value = tools
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching available MCP tools:', err)
|
||||
toast.error('加载可用 MCP 工具失败')
|
||||
} finally {
|
||||
isLoadingAvailable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load an existing tool into the shared form. Secret values arrive masked;
|
||||
// leaving them alone keeps the stored credential.
|
||||
const startEdit = (tool: MCPTool) => {
|
||||
Object.assign(createForm, {
|
||||
name: tool.name,
|
||||
description: tool.description || '',
|
||||
usage_guidance: tool.usage_guidance || '',
|
||||
transport_type: tool.transport_type,
|
||||
enabled: tool.enabled,
|
||||
command: tool.command || '',
|
||||
args: [...(tool.args || [])],
|
||||
env_vars: { ...(tool.env_vars || {}) },
|
||||
url: tool.url || '',
|
||||
headers: { ...(tool.headers || {}) },
|
||||
timeout: tool.timeout ?? DEFAULT_MCP_TIMEOUT,
|
||||
sse_read_timeout: tool.sse_read_timeout ?? 60,
|
||||
terminate_on_close: tool.terminate_on_close ?? true
|
||||
})
|
||||
editingToolId.value = tool.id
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
// Create a new MCP tool, or save the one being edited
|
||||
const saveMCPTool = async () => {
|
||||
const isEditing = editingToolId.value !== null
|
||||
try {
|
||||
const payload = { ...createForm, timeout: clampMCPTimeout(createForm.timeout) }
|
||||
|
||||
if (isEditing) {
|
||||
await mcpService.updateMCPTool(editingToolId.value as number, payload)
|
||||
} else {
|
||||
const newTool = await mcpService.createMCPTool(payload)
|
||||
// Add to agent immediately
|
||||
await mcpService.addMCPToolToAgent(newTool.id, agentId)
|
||||
}
|
||||
|
||||
// Refresh agent tools
|
||||
await fetchAgentMCPTools()
|
||||
|
||||
// Reset form and close modal
|
||||
resetCreateForm()
|
||||
showCreateModal.value = false
|
||||
editingToolId.value = null
|
||||
|
||||
toast.success(isEditing ? 'MCP 工具已更新' : 'MCP 工具已创建并关联')
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail
|
||||
|| (isEditing ? '更新 MCP 工具失败' : '创建 MCP 工具失败')
|
||||
toast.error(errorMessage)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Link existing MCP tool to agent
|
||||
const linkMCPTool = async (toolId: number) => {
|
||||
try {
|
||||
await mcpService.addMCPToolToAgent(toolId, agentId)
|
||||
await fetchAgentMCPTools()
|
||||
toast.success('MCP 工具已关联')
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || '关联 MCP 工具失败'
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Unlink MCP tool from agent
|
||||
const unlinkMCPTool = async (toolId: number) => {
|
||||
try {
|
||||
await mcpService.removeMCPToolFromAgent(toolId, agentId)
|
||||
await fetchAgentMCPTools()
|
||||
toast.success('MCP 工具已取消关联')
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || '取消关联 MCP 工具失败'
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete MCP tool
|
||||
const deleteMCPTool = async () => {
|
||||
if (!deleteTargetId.value) return
|
||||
|
||||
try {
|
||||
await mcpService.deleteMCPTool(deleteTargetId.value)
|
||||
await fetchAgentMCPTools()
|
||||
cancelDelete()
|
||||
toast.success('MCP 工具已删除')
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || '删除 MCP 工具失败'
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Connection test results, per tool id. A tool that saves fine can still be
|
||||
// dead at runtime (missing npx, bad key, unreachable server) — the Test
|
||||
// button surfaces that instead of a silent 0-tool run.
|
||||
const testResults = ref<Record<number, MCPToolTestResult>>({})
|
||||
const testingToolId = ref<number | null>(null)
|
||||
|
||||
const testMCPTool = async (toolId: number) => {
|
||||
testingToolId.value = toolId
|
||||
try {
|
||||
testResults.value = { ...testResults.value, [toolId]: await mcpService.testMCPTool(toolId) }
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || '测试 MCP 工具失败'
|
||||
testResults.value = { ...testResults.value, [toolId]: { success: false, functions: [], error: errorMessage } }
|
||||
} finally {
|
||||
testingToolId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Apply preset to form
|
||||
const applyPreset = (preset: typeof mcpPresets[0]) => {
|
||||
Object.assign(createForm, {
|
||||
...preset,
|
||||
// Presets describe themselves for humans; that copy must never become
|
||||
// prompt text the AI treats as a description of the real source.
|
||||
usage_guidance: '',
|
||||
enabled: true
|
||||
})
|
||||
}
|
||||
|
||||
// Reset create form
|
||||
const resetCreateForm = () => {
|
||||
editingToolId.value = null
|
||||
Object.assign(createForm, {
|
||||
name: '',
|
||||
description: '',
|
||||
usage_guidance: '',
|
||||
transport_type: 'stdio' as MCPTransportType,
|
||||
enabled: true,
|
||||
command: '',
|
||||
args: [],
|
||||
env_vars: {},
|
||||
url: '',
|
||||
headers: {},
|
||||
timeout: DEFAULT_MCP_TIMEOUT,
|
||||
sse_read_timeout: 60,
|
||||
terminate_on_close: true
|
||||
})
|
||||
}
|
||||
|
||||
// Confirm delete. This destroys the connector for the whole organization,
|
||||
// so the dialog first asks what else points at it.
|
||||
const confirmDelete = async (toolId: number) => {
|
||||
deleteTargetId.value = toolId
|
||||
deleteReferences.value = null
|
||||
showDeleteConfirm.value = true
|
||||
try {
|
||||
deleteReferences.value = await mcpService.getMCPToolReferences(toolId)
|
||||
} catch {
|
||||
// Non-fatal — confirm without the reference list rather than blocking.
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel delete
|
||||
const cancelDelete = () => {
|
||||
deleteTargetId.value = null
|
||||
deleteReferences.value = null
|
||||
showDeleteConfirm.value = false
|
||||
}
|
||||
|
||||
// Add argument to args array
|
||||
const addArg = (arg: string) => {
|
||||
if (arg.trim()) {
|
||||
createForm.args = [...(createForm.args || []), arg.trim()]
|
||||
}
|
||||
}
|
||||
|
||||
// Remove argument from args array
|
||||
const removeArg = (index: number) => {
|
||||
createForm.args = createForm.args?.filter((_, i) => i !== index) || []
|
||||
}
|
||||
|
||||
// Add environment variable
|
||||
const addEnvVar = (key: string, value: string) => {
|
||||
if (key.trim() && value.trim()) {
|
||||
createForm.env_vars = {
|
||||
...createForm.env_vars,
|
||||
[key.trim()]: value.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove environment variable
|
||||
const removeEnvVar = (key: string) => {
|
||||
const newEnvVars = { ...createForm.env_vars }
|
||||
delete newEnvVars[key]
|
||||
createForm.env_vars = newEnvVars
|
||||
}
|
||||
|
||||
// Add header
|
||||
const addHeader = (key: string, value: string) => {
|
||||
if (key.trim() && value.trim()) {
|
||||
createForm.headers = {
|
||||
...createForm.headers,
|
||||
[key.trim()]: value.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove header
|
||||
const removeHeader = (key: string) => {
|
||||
const newHeaders = { ...createForm.headers }
|
||||
delete newHeaders[key]
|
||||
createForm.headers = newHeaders
|
||||
}
|
||||
|
||||
// Check if tool is linked to agent
|
||||
const isToolLinked = (toolId: number): boolean => {
|
||||
return agentMCPTools.value.some(tool => tool.id === toolId)
|
||||
}
|
||||
|
||||
// Get transport type display info
|
||||
const getTransportTypeInfo = (type: MCPTransportType) => {
|
||||
return transportTypes.find(t => t.value === type) || transportTypes[0]
|
||||
}
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
agentMCPTools,
|
||||
availableMCPTools,
|
||||
isLoading,
|
||||
isLoadingAvailable,
|
||||
error,
|
||||
showCreateModal,
|
||||
showLinkModal,
|
||||
showDeleteConfirm,
|
||||
deleteReferences,
|
||||
editingToolId,
|
||||
createForm,
|
||||
transportTypes,
|
||||
mcpPresets,
|
||||
|
||||
// Methods
|
||||
fetchAgentMCPTools,
|
||||
fetchAvailableMCPTools,
|
||||
saveMCPTool,
|
||||
startEdit,
|
||||
linkMCPTool,
|
||||
unlinkMCPTool,
|
||||
deleteMCPTool,
|
||||
testMCPTool,
|
||||
testResults,
|
||||
testingToolId,
|
||||
applyPreset,
|
||||
resetCreateForm,
|
||||
confirmDelete,
|
||||
cancelDelete,
|
||||
addArg,
|
||||
removeArg,
|
||||
addEnvVar,
|
||||
removeEnvVar,
|
||||
addHeader,
|
||||
removeHeader,
|
||||
isToolLinked,
|
||||
getTransportTypeInfo,
|
||||
formatDate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One-click connect for the Meta channels.
|
||||
*
|
||||
* Three different logins hide behind one button: WhatsApp uses Meta's Embedded
|
||||
* Signup SDK, Messenger uses Facebook Login for Business and then picks a Page,
|
||||
* and Instagram uses Instagram Login, which involves no Facebook Page at all.
|
||||
* Kept out of the connect modal so that component stays presentation plus the
|
||||
* manual credentials form.
|
||||
*/
|
||||
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import channelsService, {
|
||||
type ChannelAccount,
|
||||
type MessengerSignupPage,
|
||||
} from '@/services/channels'
|
||||
import {
|
||||
loadMetaSdk,
|
||||
parseSignupMessage,
|
||||
runBusinessLogin,
|
||||
runInstagramLogin,
|
||||
signupLoginOptions,
|
||||
META_OAUTH_CALLBACK_PATH,
|
||||
type SignupChannel,
|
||||
type SignupSession,
|
||||
} from '@/utils/metaSdk'
|
||||
|
||||
/** Copy for the one-click pane, per channel. */
|
||||
const SIGNUP_COPY: Record<SignupChannel, { intro: string; cta: string }> = {
|
||||
whatsapp: {
|
||||
intro: '使用 Facebook 登录并连接你的 WhatsApp Business 号码。',
|
||||
cta: '使用 Facebook 继续',
|
||||
},
|
||||
messenger: {
|
||||
intro: '使用 Facebook 登录并连接你的主页。',
|
||||
cta: '使用 Facebook 继续',
|
||||
},
|
||||
instagram: {
|
||||
intro: '使用 Instagram 登录并连接你的专业账号。',
|
||||
cta: '使用 Instagram 继续',
|
||||
},
|
||||
}
|
||||
|
||||
export interface MetaSignupOptions {
|
||||
channel: SignupChannel
|
||||
/** Manage mode: an already-connected account has nothing to sign up for. */
|
||||
existingAccount?: ChannelAccount | null
|
||||
/** Called with the account once a login completes. */
|
||||
onConnected: (account: ChannelAccount) => void
|
||||
}
|
||||
|
||||
export function useMetaSignup({ channel, existingAccount, onConnected }: MetaSignupOptions) {
|
||||
// Offered only when the server says this deployment has a Meta app to onboard
|
||||
// under. Everyone else keeps the manual credentials form.
|
||||
const signupEnabled = ref(false)
|
||||
const signingUp = ref(false)
|
||||
const showManualForm = ref(false)
|
||||
|
||||
const configId = ref('')
|
||||
const appId = ref('')
|
||||
const graphVersion = ref('')
|
||||
|
||||
/** WhatsApp only: its two halves arrive separately and are joined once both land. */
|
||||
const signupSession = ref<SignupSession | null>(null)
|
||||
|
||||
// Facebook Login for Business can grant several Pages; the customer picks one.
|
||||
const signupPages = ref<MessengerSignupPage[]>([])
|
||||
const signupToken = ref('')
|
||||
const connectingPage = ref(false)
|
||||
|
||||
const copy = SIGNUP_COPY[channel]
|
||||
|
||||
const handleSignupMessage = (event: MessageEvent) => {
|
||||
const session = parseSignupMessage(event)
|
||||
if (session) signupSession.value = session
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (existingAccount) return
|
||||
try {
|
||||
const config = await channelsService.getEmbeddedSignupConfig(channel)
|
||||
if (!config.enabled || !config.app_id) return
|
||||
// The Facebook logins are driven by a Login-for-Business configuration;
|
||||
// Instagram Login has none, so requiring one here would hide its button.
|
||||
if (channel !== 'instagram' && !config.config_id) return
|
||||
// Empty for Instagram Login, which authorizes with the app id alone.
|
||||
configId.value = config.config_id ?? ''
|
||||
appId.value = config.app_id
|
||||
graphVersion.value = config.graph_version
|
||||
signupEnabled.value = true
|
||||
// WhatsApp uses the JS SDK (Embedded Signup, which also reports its
|
||||
// result out of band via postMessage). The other two drive their own
|
||||
// OAuth popup, so they need neither the SDK nor the message listener.
|
||||
if (channel === 'whatsapp') {
|
||||
await loadMetaSdk(config.app_id, config.graph_version)
|
||||
window.addEventListener('message', handleSignupMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
// Falling back to the manual form is a working path, not an error worth
|
||||
// interrupting the user for.
|
||||
console.error('One-click signup unavailable:', error)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener('message', handleSignupMessage))
|
||||
|
||||
const finishWhatsAppSignup = async (code: string) => {
|
||||
const session = signupSession.value
|
||||
if (!session) {
|
||||
// Signed in, but Meta never reported which number was set up, so there
|
||||
// is nothing to connect. Say so rather than appearing to do nothing.
|
||||
toast.error('WhatsApp 注册未完成', {
|
||||
description: '未设置号码。请重试,或手动输入凭据。',
|
||||
})
|
||||
return
|
||||
}
|
||||
const account = await channelsService.connectWhatsAppEmbeddedSignup({
|
||||
code,
|
||||
waba_id: session.waba_id,
|
||||
phone_number_id: session.phone_number_id,
|
||||
})
|
||||
onConnected(account)
|
||||
toast.success(`已连接 ${account.display_name || 'WhatsApp'}`)
|
||||
}
|
||||
|
||||
const connectPickedPage = async (pageId: string) => {
|
||||
const account = await channelsService.connectMessengerSignup({
|
||||
signup_token: signupToken.value,
|
||||
page_id: pageId,
|
||||
})
|
||||
onConnected(account)
|
||||
toast.success(`已连接 ${account.display_name || 'Messenger'}`)
|
||||
}
|
||||
|
||||
const finishMessengerSignup = async (code: string, redirectUri: string) => {
|
||||
const { pages, signup_token } = await channelsService.listMessengerSignupPages(code, redirectUri)
|
||||
signupToken.value = signup_token
|
||||
// One Page needs no picker — connect it and go straight to agent assignment.
|
||||
if (pages.length === 1) {
|
||||
await connectPickedPage(pages[0].id)
|
||||
} else {
|
||||
signupPages.value = pages
|
||||
}
|
||||
}
|
||||
|
||||
/** The picker's choice — its own loading state, since the login popup is long gone. */
|
||||
const onPageSelected = async (pageId: string) => {
|
||||
try {
|
||||
connectingPage.value = true
|
||||
await connectPickedPage(pageId)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.detail || '无法连接该账号')
|
||||
} finally {
|
||||
connectingPage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// WhatsApp: the JS SDK's login callback.
|
||||
const completeWhatsAppSignup = async (response: { authResponse?: { code?: string } }) => {
|
||||
const code = response.authResponse?.code
|
||||
if (!code) {
|
||||
// The popup was dismissed — not an error worth a toast.
|
||||
signingUp.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
await finishWhatsAppSignup(code)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.detail || '无法完成连接')
|
||||
} finally {
|
||||
signingUp.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared shape of the two popup logins: run it, connect, surface failures. */
|
||||
const runPopupConnect = async (connect: (redirectUri: string) => Promise<void>) => {
|
||||
signingUp.value = true
|
||||
const redirectUri = window.location.origin + META_OAUTH_CALLBACK_PATH
|
||||
try {
|
||||
await connect(redirectUri)
|
||||
} catch (error: any) {
|
||||
// The user closing the popup is not an error worth a toast.
|
||||
if (error?.message !== 'cancelled') {
|
||||
toast.error(error?.response?.data?.detail || error?.message || '无法完成连接')
|
||||
}
|
||||
} finally {
|
||||
signingUp.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Messenger: Facebook Login for Business, then pick a Page.
|
||||
const startMessengerLogin = () => runPopupConnect(async (redirectUri) => {
|
||||
const code = await runBusinessLogin({
|
||||
appId: appId.value,
|
||||
configId: configId.value,
|
||||
graphVersion: graphVersion.value,
|
||||
redirectUri,
|
||||
})
|
||||
await finishMessengerSignup(code, redirectUri)
|
||||
})
|
||||
|
||||
// Instagram: Instagram Login — one account, so it connects in a single step.
|
||||
const startInstagramLogin = () => runPopupConnect(async (redirectUri) => {
|
||||
const code = await runInstagramLogin({ appId: appId.value, redirectUri })
|
||||
const account = await channelsService.connectInstagramLogin(code, redirectUri)
|
||||
onConnected(account)
|
||||
toast.success(`已连接 ${account.display_name || 'Instagram'}`)
|
||||
})
|
||||
|
||||
const startSignup = () => {
|
||||
if (signingUp.value) return
|
||||
|
||||
// Both popups must open synchronously in the click handler or the browser
|
||||
// blocks them.
|
||||
if (channel === 'messenger') { void startMessengerLogin(); return }
|
||||
if (channel === 'instagram') { void startInstagramLogin(); return }
|
||||
|
||||
if (!window.FB) return
|
||||
signupSession.value = null
|
||||
signingUp.value = true
|
||||
// The SDK type-checks its callback and rejects an AsyncFunction outright
|
||||
// ("Expression is of type asyncfunction, not function"), so hand the async
|
||||
// work off from a plain function rather than passing `async` here.
|
||||
window.FB.login(
|
||||
(response) => { void completeWhatsAppSignup(response) },
|
||||
signupLoginOptions(configId.value, channel),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
signupEnabled,
|
||||
signingUp,
|
||||
showManualForm,
|
||||
signupPages,
|
||||
connectingPage,
|
||||
copy,
|
||||
startSignup,
|
||||
onPageSelected,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { notificationService, type NotificationSettings } from '@/services/notification'
|
||||
|
||||
export function useNotificationSettings() {
|
||||
const settings = ref<NotificationSettings | null>(null)
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
settings.value = await notificationService.getSettings()
|
||||
error.value = null
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || '加载通知设置失败'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Optimistic: the toggle flips immediately and reverts if the save fails,
|
||||
// so a slow round-trip never makes the switch feel unresponsive.
|
||||
async function save(patch: Partial<NotificationSettings>) {
|
||||
if (!settings.value || isSaving.value) return
|
||||
isSaving.value = true
|
||||
const previous = { ...settings.value }
|
||||
Object.assign(settings.value, patch)
|
||||
try {
|
||||
settings.value = await notificationService.updateSettings(patch)
|
||||
} catch (e: any) {
|
||||
settings.value = previous
|
||||
toast.error(e?.message || '保存通知设置失败')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
return { settings, isLoading, isSaving, error, load, save }
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { onMessage, type MessagePayload } from 'firebase/messaging'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
requestNotificationPermission,
|
||||
refreshFCMToken,
|
||||
getMessagingIfSupported
|
||||
} from '@/services/firebase'
|
||||
import { SW_MESSAGE, CONVERSATIONS_PATH, conversationSessionUrl } from '@/pwa/pushContract'
|
||||
import mitt from '@/utils/emitter'
|
||||
|
||||
// Match backend NotificationType enum
|
||||
enum NotificationType {
|
||||
KNOWLEDGE_PROCESSED = 'knowledge_processed',
|
||||
KNOWLEDGE_FAILED = 'knowledge_failed',
|
||||
SYSTEM = 'system',
|
||||
CHAT = 'chat'
|
||||
}
|
||||
|
||||
// Module-level guards: every view mounts its own DashboardLayout (and thus
|
||||
// this composable), so per-mount attachment would stack a new SW message
|
||||
// listener + FCM onMessage subscription on every route change — one push
|
||||
// would then toast once per navigation.
|
||||
let listenersAttached = false
|
||||
let tokenRefreshed = false
|
||||
|
||||
export function useNotifications() {
|
||||
const hasPermission = ref(
|
||||
typeof Notification !== 'undefined' && Notification.permission === 'granted'
|
||||
)
|
||||
const router = useRouter()
|
||||
|
||||
// A payload that references a conversation gets an "Open" action on its toast
|
||||
const sessionAction = (payload: MessagePayload) => {
|
||||
const sessionId = payload.data?.session_id
|
||||
if (!sessionId) return undefined
|
||||
return {
|
||||
label: '打开',
|
||||
onClick: () => router.push(conversationSessionUrl(sessionId))
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewNotification = (payload: MessagePayload) => {
|
||||
const notifType = payload.data?.type?.toLowerCase() || 'default'
|
||||
// Backend sends data-only messages (title/body inside data) so the SW SDK
|
||||
// doesn't auto-display duplicates; keep notification?. as a fallback.
|
||||
const title = payload.data?.title || payload.notification?.title
|
||||
const body = payload.data?.body || payload.notification?.body
|
||||
|
||||
// Handle different notification types
|
||||
switch (notifType) {
|
||||
case NotificationType.KNOWLEDGE_PROCESSED:
|
||||
toast.success(title || '知识库处理完成', {
|
||||
description: body,
|
||||
duration: 5000,
|
||||
position: 'top-right'
|
||||
})
|
||||
// Emit event to refresh knowledge grid
|
||||
mitt.emit('knowledge-updated')
|
||||
break
|
||||
|
||||
case NotificationType.KNOWLEDGE_FAILED:
|
||||
toast.error(title || '知识库处理失败', {
|
||||
description: body,
|
||||
duration: 8000,
|
||||
position: 'top-right'
|
||||
})
|
||||
break
|
||||
|
||||
case NotificationType.SYSTEM:
|
||||
toast(title || '系统通知', {
|
||||
description: body,
|
||||
duration: 5000,
|
||||
position: 'top-right',
|
||||
action: sessionAction(payload)
|
||||
})
|
||||
break
|
||||
|
||||
case NotificationType.CHAT:
|
||||
toast(title || '新会话消息', {
|
||||
description: body,
|
||||
duration: 5000,
|
||||
position: 'top-right',
|
||||
action: sessionAction(payload)
|
||||
})
|
||||
break
|
||||
|
||||
default:
|
||||
toast(title || '新通知', {
|
||||
description: body,
|
||||
duration: 5000,
|
||||
position: 'top-right'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const listenForBgNotification = () => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
if (event.data?.eventType === SW_MESSAGE.BACKGROUND_NOTIFICATION) {
|
||||
handleNewNotification(event.data)
|
||||
}
|
||||
// SW notificationclick fallback for tabs it can't navigate() itself.
|
||||
// Pin the target to the inbox — never route to an arbitrary string
|
||||
// from a message event.
|
||||
if (
|
||||
event.data?.eventType === SW_MESSAGE.OPEN_CONVERSATION &&
|
||||
typeof event.data.url === 'string' &&
|
||||
event.data.url.startsWith(CONVERSATIONS_PATH)
|
||||
) {
|
||||
router.push(event.data.url)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const setFcmMessagingListener = async () => {
|
||||
const messaging = await getMessagingIfSupported()
|
||||
if (!messaging) return
|
||||
onMessage(messaging, (payload) => {
|
||||
handleNewNotification(payload)
|
||||
})
|
||||
}
|
||||
|
||||
// Attach listeners — safe on mount, guarded to run once per app load. Does
|
||||
// NOT ask for permission: on iOS a permission request outside a user gesture
|
||||
// silently fails, so the request lives behind an explicit button
|
||||
// (EnablePushPrompt → enableNotifications).
|
||||
const startListening = async () => {
|
||||
try {
|
||||
if (!hasPermission.value || listenersAttached) return
|
||||
listenersAttached = true
|
||||
listenForBgNotification()
|
||||
await setFcmMessagingListener()
|
||||
|
||||
// Already-granted users never see the enable prompt again, so this is
|
||||
// the only path that re-binds their FCM token to the current service
|
||||
// worker (the old firebase-messaging-sw.js one dies on upgrade).
|
||||
if (!tokenRefreshed) {
|
||||
tokenRefreshed = true
|
||||
refreshFCMToken()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error initializing notification listeners:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Gesture-driven: prompt for permission, register the FCM token, then listen
|
||||
const enableNotifications = async (): Promise<boolean> => {
|
||||
const granted = await requestNotificationPermission()
|
||||
hasPermission.value = granted
|
||||
if (granted) {
|
||||
// Token was just minted — don't mint again from startListening
|
||||
tokenRefreshed = true
|
||||
await startListening()
|
||||
} else if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||||
// Permission granted but token registration failed — surface it
|
||||
toast.error('无法开启通知。请刷新后重试。')
|
||||
}
|
||||
return granted
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startListening()
|
||||
})
|
||||
|
||||
return {
|
||||
hasPermission,
|
||||
enableNotifications,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { ticketService } from '@/services/tickets'
|
||||
import { useTicketSocket } from '@/composables/useTicketSocket'
|
||||
import type {
|
||||
InvestigationDetail,
|
||||
Ticket,
|
||||
TicketActivity,
|
||||
TicketDetail,
|
||||
TicketPriority,
|
||||
TicketStatus,
|
||||
TicketUpdatePayload,
|
||||
} from '@/types/ticket'
|
||||
|
||||
// Poll fallback cadence: brisk while an AI run is active, relaxed otherwise.
|
||||
const ACTIVE_POLL_MS = 3000
|
||||
const IDLE_POLL_MS = 15000
|
||||
|
||||
export function useTicketDetail(ticketId: Ref<string>) {
|
||||
const detail = ref<TicketDetail | null>(null)
|
||||
const investigation = ref<InvestigationDetail | null>(null)
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const isSavingComment = ref(false)
|
||||
// Covers both customer-summary actions: they write the same field, and
|
||||
// sending twice delivers the message to the customer twice.
|
||||
const isRcaBusy = ref(false)
|
||||
|
||||
const ticket = computed<Ticket | null>(() => detail.value?.ticket ?? null)
|
||||
const activities = computed<TicketActivity[]>(() => detail.value?.activities ?? [])
|
||||
const hasActiveRun = computed(() =>
|
||||
(detail.value?.runs ?? []).some((r) => r.status === 'pending' || r.status === 'running'),
|
||||
)
|
||||
|
||||
async function refresh(silent = false) {
|
||||
if (!ticketId.value) return
|
||||
if (!silent) isLoading.value = true
|
||||
try {
|
||||
// The glass box loads alongside the ticket; its failure (e.g. plan
|
||||
// gate) must never take the whole page down.
|
||||
const [detailData, investigationData] = await Promise.all([
|
||||
ticketService.getTicket(ticketId.value),
|
||||
ticketService.getInvestigation(ticketId.value).catch(() => null),
|
||||
])
|
||||
detail.value = detailData
|
||||
investigation.value = investigationData
|
||||
error.value = null
|
||||
} catch (e: any) {
|
||||
if (!silent) error.value = e?.message || '加载工单失败'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function patch(patchPayload: TicketUpdatePayload, failureMessage: string) {
|
||||
if (!detail.value) return
|
||||
const previous = { ...detail.value.ticket }
|
||||
Object.assign(detail.value.ticket, patchPayload)
|
||||
try {
|
||||
await ticketService.updateTicket(previous.id, patchPayload)
|
||||
await refresh(true)
|
||||
} catch (e: any) {
|
||||
detail.value.ticket = previous
|
||||
toast.error(e?.message || failureMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const setStatus = (status: TicketStatus) => patch({ status }, '修改状态失败')
|
||||
const setPriority = (priority: TicketPriority) => patch({ priority }, '修改优先级失败')
|
||||
const setSeverity = (severity: number) => patch({ severity }, '修改严重程度失败')
|
||||
const setTitle = (title: string) => patch({ title }, '重命名工单失败')
|
||||
const setDescription = (description: string) =>
|
||||
patch({ description }, '更新描述失败')
|
||||
const setAssignee = (assignee_user_id: string | null) =>
|
||||
patch({ assignee_user_id }, '分配工单失败')
|
||||
|
||||
async function setCustomer(email: string, name?: string) {
|
||||
if (!detail.value || !email.trim()) return
|
||||
try {
|
||||
await ticketService.updateTicket(detail.value.ticket.id, {
|
||||
customer_email: email.trim(),
|
||||
customer_name: name?.trim() || undefined,
|
||||
})
|
||||
await refresh(true)
|
||||
toast.success('客户已关联')
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '关联客户失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function addComment(body: string, isInternal = true) {
|
||||
if (!detail.value || !body.trim()) return
|
||||
isSavingComment.value = true
|
||||
try {
|
||||
await ticketService.addComment(detail.value.ticket.id, body.trim(), isInternal)
|
||||
await refresh(true)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '发表评论失败')
|
||||
} finally {
|
||||
isSavingComment.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(payload: { outcome?: string; resolution_summary?: string; customer_message?: string }) {
|
||||
if (!detail.value) return
|
||||
try {
|
||||
await ticketService.resolveTicket(detail.value.ticket.id, payload)
|
||||
await refresh(true)
|
||||
toast.success('工单已解决,已通知客户')
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '解决工单失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function reopen(reason?: string) {
|
||||
if (!detail.value) return
|
||||
try {
|
||||
await ticketService.reopenTicket(detail.value.ticket.id, reason)
|
||||
await refresh(true)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '重新打开工单失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function investigate(contextNote?: string) {
|
||||
if (!detail.value) return
|
||||
try {
|
||||
await ticketService.investigate(detail.value.ticket.id, {
|
||||
run_type: 'investigation',
|
||||
context_note: contextNote,
|
||||
})
|
||||
await refresh(true)
|
||||
toast.success('AI 调查已加入队列')
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '启动 AI 运行失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRcaDraft(customerSummary: string) {
|
||||
if (!detail.value || isRcaBusy.value) return
|
||||
isRcaBusy.value = true
|
||||
try {
|
||||
await ticketService.updateRca(detail.value.ticket.id, {
|
||||
customer_summary: customerSummary,
|
||||
mark_reviewed: true,
|
||||
})
|
||||
await refresh(true)
|
||||
toast.success('草稿已保存')
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '保存草稿失败')
|
||||
} finally {
|
||||
isRcaBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRcaToCustomer(customerSummary?: string) {
|
||||
// Re-entry guard, not just a disabled button: this delivers a message to
|
||||
// a real customer, so a double-click must not send it twice.
|
||||
if (!detail.value || isRcaBusy.value) return
|
||||
isRcaBusy.value = true
|
||||
try {
|
||||
// Persist any unsaved edit first so what's sent is what's shown.
|
||||
if (customerSummary !== undefined) {
|
||||
await ticketService.updateRca(detail.value.ticket.id, {
|
||||
customer_summary: customerSummary,
|
||||
mark_reviewed: true,
|
||||
})
|
||||
}
|
||||
await ticketService.sendRcaToCustomer(detail.value.ticket.id)
|
||||
await refresh(true)
|
||||
toast.success('摘要已发送给客户')
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '发送摘要失败')
|
||||
} finally {
|
||||
isRcaBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function approveProposal() {
|
||||
if (!detail.value) return
|
||||
try {
|
||||
await ticketService.approveProposal(detail.value.ticket.id)
|
||||
await refresh(true)
|
||||
toast.success('方案已批准,工单已解决并通知客户')
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '批准方案失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectProposal(reason?: string, reinvestigate = false) {
|
||||
if (!detail.value) return
|
||||
try {
|
||||
await ticketService.rejectProposal(detail.value.ticket.id, reason, reinvestigate)
|
||||
await refresh(true)
|
||||
toast.success(reinvestigate ? '已拒绝,已排入新的深入调查' : '方案已拒绝')
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '拒绝方案失败')
|
||||
}
|
||||
}
|
||||
|
||||
useTicketSocket((event) => {
|
||||
if (event.ticket_id === ticketId.value) refresh(true)
|
||||
})
|
||||
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function scheduleNextPoll() {
|
||||
pollTimer = setTimeout(async () => {
|
||||
await refresh(true)
|
||||
scheduleNextPoll()
|
||||
}, hasActiveRun.value ? ACTIVE_POLL_MS : IDLE_POLL_MS)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refresh()
|
||||
scheduleNextPoll()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (pollTimer) clearTimeout(pollTimer)
|
||||
})
|
||||
watch(ticketId, () => refresh())
|
||||
|
||||
return {
|
||||
detail,
|
||||
investigation,
|
||||
ticket,
|
||||
activities,
|
||||
hasActiveRun,
|
||||
isLoading,
|
||||
isSavingComment,
|
||||
isRcaBusy,
|
||||
error,
|
||||
refresh,
|
||||
setStatus,
|
||||
setPriority,
|
||||
setSeverity,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setAssignee,
|
||||
setCustomer,
|
||||
addComment,
|
||||
resolve,
|
||||
reopen,
|
||||
investigate,
|
||||
saveRcaDraft,
|
||||
sendRcaToCustomer,
|
||||
approveProposal,
|
||||
rejectProposal,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Live ticket updates: joins the per-org ticket room on the /agent namespace
|
||||
and forwards `ticket_update` frames. Callers keep a poll fallback — a missed
|
||||
frame is never fatal.
|
||||
*/
|
||||
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { socketService } from '@/services/socket'
|
||||
import { userService } from '@/services/user'
|
||||
import type { TicketUpdateEvent } from '@/types/ticket'
|
||||
|
||||
export function useTicketSocket(onUpdate: (event: TicketUpdateEvent) => void) {
|
||||
const orgId = userService.getCurrentUser()?.organization_id
|
||||
const room = orgId ? `org_tickets_${orgId}` : null
|
||||
|
||||
const handleUpdate = (data: unknown) => {
|
||||
const event = data as TicketUpdateEvent
|
||||
if (event && event.ticket_id) onUpdate(event)
|
||||
}
|
||||
|
||||
const joinRoom = () => {
|
||||
if (room) socketService.emit('join_room', { session_id: room })
|
||||
}
|
||||
|
||||
const handleReconnect = () => {
|
||||
socketService.off('ticket_update', handleUpdate)
|
||||
socketService.on('ticket_update', handleUpdate)
|
||||
joinRoom()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!room) return
|
||||
socketService.connect()
|
||||
socketService.on('ticket_update', handleUpdate)
|
||||
socketService.onReconnect(handleReconnect)
|
||||
joinRoom()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!room) return
|
||||
socketService.emit('leave_room', { session_id: room })
|
||||
socketService.off('ticket_update', handleUpdate)
|
||||
socketService.offReconnect(handleReconnect)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { ticketService } from '@/services/tickets'
|
||||
import type { TicketSettings } from '@/types/ticket'
|
||||
|
||||
export function useTicketingSettings() {
|
||||
const settings = ref<TicketSettings | null>(null)
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const planGated = ref(false)
|
||||
|
||||
async function load() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
settings.value = await ticketService.getSettings()
|
||||
error.value = null
|
||||
planGated.value = false
|
||||
} catch (e: any) {
|
||||
planGated.value = /plan/i.test(e?.message || '')
|
||||
error.value = e?.message || '加载工单设置失败'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save(patch: Partial<TicketSettings>) {
|
||||
if (!settings.value || isSaving.value) return
|
||||
isSaving.value = true
|
||||
const previous = { ...settings.value }
|
||||
Object.assign(settings.value, patch)
|
||||
try {
|
||||
settings.value = await ticketService.updateSettings(patch)
|
||||
toast.success('工单设置已保存')
|
||||
} catch (e: any) {
|
||||
settings.value = previous
|
||||
toast.error(e?.message || '保存工单设置失败')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
return { settings, isLoading, isSaving, error, planGated, load, save }
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ticketService } from '@/services/tickets'
|
||||
import { useTicketSocket } from '@/composables/useTicketSocket'
|
||||
import type { Pagination, TicketListFilters, TicketListItem, TicketStats } from '@/types/ticket'
|
||||
|
||||
// Server-side filter mapping for the status pills. "breaching" is a virtual
|
||||
// filter (SLA), approximated client-side over the loaded page.
|
||||
const OPEN_STATUSES = 'open,triaging,investigating,awaiting_approval,in_progress,reopened,resolved_pending_confirmation'
|
||||
const SEARCH_DEBOUNCE_MS = 350
|
||||
// Poll fallback so a missed socket frame never strands the list.
|
||||
const LIST_REFRESH_MS = 30000
|
||||
|
||||
const DEFAULT_FILTERS: TicketListFilters = {
|
||||
status: 'all',
|
||||
priority: 'all',
|
||||
assignee: 'all',
|
||||
ai: 'all',
|
||||
search: '',
|
||||
sort: 'updated',
|
||||
}
|
||||
|
||||
export function useTicketsWorkspace() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tickets = ref<TicketListItem[]>([])
|
||||
const stats = ref<TicketStats | null>(null)
|
||||
const pagination = ref<Pagination | null>(null)
|
||||
const page = ref(1)
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const planGated = ref(false)
|
||||
|
||||
const filters = reactive<TicketListFilters>({
|
||||
...DEFAULT_FILTERS,
|
||||
...pickFiltersFromQuery(route.query),
|
||||
})
|
||||
|
||||
const phase = computed(() => {
|
||||
if (isLoading.value && tickets.value.length === 0) return 'loading'
|
||||
if (tickets.value.length === 0) return 'empty'
|
||||
return 'populated'
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
Object.keys(DEFAULT_FILTERS).some(
|
||||
(key) => filters[key as keyof TicketListFilters] !== DEFAULT_FILTERS[key as keyof TicketListFilters],
|
||||
),
|
||||
)
|
||||
|
||||
function pickFiltersFromQuery(query: Record<string, any>): Partial<TicketListFilters> {
|
||||
const picked: Partial<TicketListFilters> = {}
|
||||
for (const key of Object.keys(DEFAULT_FILTERS) as (keyof TicketListFilters)[]) {
|
||||
if (typeof query[key] === 'string' && query[key]) picked[key] = query[key]
|
||||
}
|
||||
return picked
|
||||
}
|
||||
|
||||
function filtersToQuery(): Record<string, string> {
|
||||
const query: Record<string, string> = {}
|
||||
for (const key of Object.keys(DEFAULT_FILTERS) as (keyof TicketListFilters)[]) {
|
||||
if (filters[key] !== DEFAULT_FILTERS[key]) query[key] = filters[key]
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
function buildParams() {
|
||||
const params: Record<string, any> = { page: page.value, sort: filters.sort }
|
||||
if (filters.status === 'open') params.status = OPEN_STATUSES
|
||||
else if (filters.status === 'awaiting') params.status = 'awaiting_approval'
|
||||
else if (filters.status === 'resolved') params.status = 'resolved,closed'
|
||||
else if (filters.status === 'breaching') params.status = OPEN_STATUSES
|
||||
if (filters.priority !== 'all') params.priority = filters.priority
|
||||
if (filters.assignee === 'unassigned') params.unassigned = true
|
||||
else if (filters.assignee !== 'all') params.assignee_id = filters.assignee
|
||||
if (filters.ai !== 'all') params.ai_state = filters.ai
|
||||
if (filters.search.trim()) params.search = filters.search.trim()
|
||||
return params
|
||||
}
|
||||
|
||||
const visibleTickets = computed(() => {
|
||||
if (filters.status !== 'breaching') return tickets.value
|
||||
const soon = Date.now() + 30 * 60000
|
||||
return tickets.value.filter(
|
||||
(t) => t.sla_due_at && !t.resolved_at && new Date(t.sla_due_at).getTime() <= soon,
|
||||
)
|
||||
})
|
||||
|
||||
async function refresh(silent = false) {
|
||||
if (!silent) isLoading.value = true
|
||||
try {
|
||||
const [listResponse, statsResponse] = await Promise.all([
|
||||
ticketService.listTickets(buildParams()),
|
||||
ticketService.getStats(),
|
||||
])
|
||||
tickets.value = listResponse.tickets
|
||||
pagination.value = listResponse.pagination
|
||||
stats.value = statsResponse
|
||||
error.value = null
|
||||
planGated.value = false
|
||||
} catch (e: any) {
|
||||
if (!silent) {
|
||||
planGated.value = /plan/i.test(e?.message || '')
|
||||
error.value = e?.message || '加载工单失败'
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
Object.assign(filters, DEFAULT_FILTERS)
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
// Filters -> URL (shareable) + reload. Search is debounced.
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(
|
||||
() => ({ ...filters }),
|
||||
(next, prev) => {
|
||||
router.replace({ query: filtersToQuery() })
|
||||
if (next.search !== prev?.search) {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
refresh()
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
return
|
||||
}
|
||||
page.value = 1
|
||||
refresh()
|
||||
},
|
||||
)
|
||||
watch(page, () => refresh())
|
||||
|
||||
useTicketSocket(() => refresh(true))
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
refreshTimer = setInterval(() => refresh(true), LIST_REFRESH_MS)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
})
|
||||
|
||||
return {
|
||||
tickets: visibleTickets,
|
||||
stats,
|
||||
pagination,
|
||||
page,
|
||||
filters,
|
||||
phase,
|
||||
isLoading,
|
||||
error,
|
||||
planGated,
|
||||
hasActiveFilters,
|
||||
refresh,
|
||||
clearFilters,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ref, type Ref } from 'vue'
|
||||
import type { Node, Edge } from '@vue-flow/core'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { workflowNodeService } from '@/services/workflowNode'
|
||||
import { workflowService } from '@/services/workflow'
|
||||
import { workflowCacheStorage } from '@/utils/storage'
|
||||
import { WorkflowStatus } from '@/types/workflow'
|
||||
import type { NodeTypeInfo, ValidationResult } from './useWorkflowValidation'
|
||||
|
||||
export interface WorkflowManagementOptions {
|
||||
workflowId: string
|
||||
selectedNode: Ref<Node | null>
|
||||
showPropertiesPanel: Ref<boolean>
|
||||
loading: Ref<boolean>
|
||||
publishLoading: Ref<boolean>
|
||||
workflowStatus: Ref<WorkflowStatus>
|
||||
nodeIdCounter: Ref<number>
|
||||
availableNodeTypes: NodeTypeInfo[]
|
||||
validateAllNodes: (nodes: Node[], workflowId: string, edges?: any[]) => ValidationResult
|
||||
highlightNodesWithErrors: (nodes: Node[], workflowId: string, availableNodeTypes: NodeTypeInfo[], edges?: any[]) => void
|
||||
resetAllNodesValidationStyle: (nodes: Node[], availableNodeTypes: NodeTypeInfo[]) => void
|
||||
getNodes: () => Node[]
|
||||
getEdges: () => Edge[]
|
||||
addNodes: (nodes: Node[]) => void
|
||||
addEdges: (edges: Edge[]) => void
|
||||
removeNodes: (nodeIds: string[]) => void
|
||||
closePropertiesPanel: () => void
|
||||
}
|
||||
|
||||
export function useWorkflowManagement(options: WorkflowManagementOptions) {
|
||||
const {
|
||||
workflowId,
|
||||
selectedNode,
|
||||
showPropertiesPanel,
|
||||
loading,
|
||||
publishLoading,
|
||||
workflowStatus,
|
||||
nodeIdCounter,
|
||||
availableNodeTypes,
|
||||
validateAllNodes,
|
||||
highlightNodesWithErrors,
|
||||
resetAllNodesValidationStyle,
|
||||
getNodes,
|
||||
getEdges,
|
||||
addNodes,
|
||||
addEdges,
|
||||
removeNodes,
|
||||
closePropertiesPanel
|
||||
} = options
|
||||
|
||||
// Map backend node types to frontend enum values
|
||||
const mapNodeTypeToFrontend = (backendType: string) => {
|
||||
const mapping = {
|
||||
'landing_page': 'landingPage',
|
||||
'message': 'message',
|
||||
'llm': 'llm',
|
||||
'condition': 'condition',
|
||||
'form': 'form',
|
||||
'action': 'action',
|
||||
'human_transfer': 'humanTransfer',
|
||||
'wait': 'wait',
|
||||
'end': 'end',
|
||||
'user_input': 'userInput',
|
||||
'guardrails': 'guardrails'
|
||||
}
|
||||
return mapping[backendType as keyof typeof mapping] || 'message'
|
||||
}
|
||||
|
||||
// Map frontend node types to backend enum values
|
||||
const mapNodeTypeToBackend = (frontendType: string) => {
|
||||
const mapping = {
|
||||
'landingPage': 'landing_page',
|
||||
'message': 'message',
|
||||
'llm': 'llm',
|
||||
'condition': 'condition',
|
||||
'form': 'form',
|
||||
'action': 'action',
|
||||
'humanTransfer': 'human_transfer',
|
||||
'wait': 'wait',
|
||||
'end': 'end',
|
||||
'userInput': 'user_input',
|
||||
'guardrails': 'guardrails'
|
||||
}
|
||||
return mapping[frontendType as keyof typeof mapping] || 'message'
|
||||
}
|
||||
|
||||
// Load workflow data
|
||||
const loadWorkflowData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// Clear any existing cache and load fresh from API
|
||||
workflowCacheStorage.clearWorkflowCache(workflowId)
|
||||
const data = await workflowNodeService.getWorkflowNodes(workflowId, false)
|
||||
|
||||
// Convert backend nodes to Vue Flow nodes
|
||||
const nodes: Node[] = data.nodes.map((node: any) => {
|
||||
const frontendNodeType = mapNodeTypeToFrontend(node.node_type)
|
||||
const nodeType = availableNodeTypes.find(t => t.type === frontendNodeType)
|
||||
return {
|
||||
id: node.id,
|
||||
type: 'default',
|
||||
position: { x: node.position_x, y: node.position_y },
|
||||
data: {
|
||||
label: `${nodeType?.icon || '📄'} ${node.name}`,
|
||||
cleanName: node.name,
|
||||
description: node.description,
|
||||
config: node.config,
|
||||
nodeType: frontendNodeType,
|
||||
icon: nodeType?.icon || '📄',
|
||||
color: nodeType?.color || '#6B7280',
|
||||
// Store all backend properties directly in data for easy access
|
||||
message_text: node.config?.message_text,
|
||||
system_prompt: node.system_prompt,
|
||||
temperature: node.temperature,
|
||||
model_id: node.model_id,
|
||||
form_fields: node.form_fields,
|
||||
form_title: node.form_title,
|
||||
form_description: node.form_description,
|
||||
submit_button_text: node.submit_button_text,
|
||||
form_full_screen: node.form_full_screen,
|
||||
condition_expression: node.condition_expression,
|
||||
action_type: node.action_type,
|
||||
action_config: node.action_config,
|
||||
transfer_rules: node.transfer_rules,
|
||||
wait_duration: node.wait_duration,
|
||||
wait_until_condition: node.wait_until_condition,
|
||||
landing_page_heading: node.landing_page_heading,
|
||||
landing_page_content: node.landing_page_content,
|
||||
// Also spread config for backward compatibility
|
||||
...node.config
|
||||
},
|
||||
style: {
|
||||
backgroundColor: nodeType?.color || '#6B7280',
|
||||
color: 'white',
|
||||
borderColor: nodeType?.color || '#6B7280'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Convert backend connections to Vue Flow edges
|
||||
const edges: Edge[] = data.connections.map((conn: any) => ({
|
||||
id: conn.id,
|
||||
source: conn.source_node_id,
|
||||
target: conn.target_node_id,
|
||||
sourceHandle: conn.source_handle,
|
||||
targetHandle: conn.target_handle,
|
||||
label: conn.label,
|
||||
data: {
|
||||
condition: conn.condition,
|
||||
priority: conn.priority,
|
||||
metadata: conn.connection_metadata
|
||||
}
|
||||
}))
|
||||
|
||||
addNodes(nodes)
|
||||
addEdges(edges)
|
||||
|
||||
// Update counter for new nodes
|
||||
const maxCounter = nodes.reduce((max, node) => {
|
||||
const match = node.id.match(/-(\d+)$/)
|
||||
if (match) {
|
||||
return Math.max(max, parseInt(match[1]))
|
||||
}
|
||||
return max
|
||||
}, 0)
|
||||
nodeIdCounter.value = maxCounter + 1
|
||||
} catch (error: any) {
|
||||
console.error('Error loading workflow data:', error)
|
||||
|
||||
// Don't show error toast for 404 - it means workflow exists but has no nodes yet
|
||||
if (error.response?.status !== 404) {
|
||||
toast.error('加载工作流数据失败', {
|
||||
position: 'top-center'
|
||||
})
|
||||
}
|
||||
|
||||
if (error.response?.status === 404) {
|
||||
console.log('No nodes found for workflow - starting with empty canvas')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Save node properties
|
||||
const saveNodeProperties = (properties: any) => {
|
||||
if (selectedNode.value) {
|
||||
// Update the node visual data (label and styling)
|
||||
const nodeType = availableNodeTypes.find(t => t.type === selectedNode.value?.data.nodeType)
|
||||
selectedNode.value.data.label = `${nodeType?.icon || '📄'} ${properties.name || selectedNode.value.data.cleanName}`
|
||||
selectedNode.value.data.cleanName = properties.name || selectedNode.value.data.cleanName
|
||||
selectedNode.value.data.description = properties.description || selectedNode.value.data.description
|
||||
}
|
||||
|
||||
// Reset all validation styling to refresh the validation state based on latest cache data
|
||||
resetAllNodesValidationStyle(getNodes(), availableNodeTypes)
|
||||
|
||||
// Re-validate to ensure errors are cleared if the node is now valid
|
||||
setTimeout(() => {
|
||||
const validation = validateAllNodes(getNodes(), workflowId, getEdges())
|
||||
if (!validation.isValid) {
|
||||
highlightNodesWithErrors(getNodes(), workflowId, availableNodeTypes, getEdges())
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// Save workflow
|
||||
const saveWorkflow = async () => {
|
||||
const nodes = getNodes()
|
||||
const edges = getEdges()
|
||||
|
||||
// Check if there are no nodes to save
|
||||
if (nodes.length === 0) {
|
||||
toast.error('无法保存空工作流,请至少添加一个节点。', {
|
||||
duration: 4000,
|
||||
closeButton: true,
|
||||
position: 'top-center'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for at least one connection if multiple nodes exist
|
||||
if (nodes.length > 1 && edges.length === 0) {
|
||||
toast.error('无法保存未连接的工作流,请先连接节点再保存。', {
|
||||
duration: 5000,
|
||||
closeButton: true,
|
||||
position: 'top-center'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// For single node workflows, ensure it's a valid starting node type
|
||||
if (nodes.length === 1) {
|
||||
const singleNode = nodes[0]
|
||||
const validStartingNodeTypes = ['landingPage', 'message', 'llm', 'form']
|
||||
if (!validStartingNodeTypes.includes(singleNode.data.nodeType)) {
|
||||
toast.error(`工作流仅包含一个 ${singleNode.data.nodeType} 节点,无法保存。工作流必须以落地页、消息、LLM 或表单节点开始。`, {
|
||||
duration: 6000,
|
||||
closeButton: true,
|
||||
position: 'top-center'
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced connection validation: Check for isolated nodes
|
||||
if (nodes.length > 1) {
|
||||
const connectedNodeIds = new Set<string>()
|
||||
|
||||
edges.forEach(edge => {
|
||||
connectedNodeIds.add(edge.source)
|
||||
connectedNodeIds.add(edge.target)
|
||||
})
|
||||
|
||||
const isolatedNodes = nodes.filter(node => !connectedNodeIds.has(node.id))
|
||||
|
||||
if (isolatedNodes.length > 0) {
|
||||
const isolatedNodeNames = isolatedNodes.map(node =>
|
||||
node.data.cleanName || node.data.label || '未命名节点'
|
||||
).join(', ')
|
||||
|
||||
toast.error(`存在孤立节点,无法保存工作流:${isolatedNodeNames}。请先连接所有节点再保存。`, {
|
||||
duration: 6000,
|
||||
closeButton: true,
|
||||
position: 'top-center'
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate all nodes have required configuration
|
||||
const validation = validateAllNodes(nodes, workflowId, edges)
|
||||
if (!validation.isValid) {
|
||||
highlightNodesWithErrors(nodes, workflowId, availableNodeTypes, edges)
|
||||
toast.error('工作流存在校验错误,无法保存:\n' + validation.errors.join('\n'), {
|
||||
duration: 8000,
|
||||
closeButton: true,
|
||||
position: 'top-center'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// Save entire cache to backend and get the updated data with real UUIDs
|
||||
const savedData = await workflowNodeService.saveWorkflowCache(workflowId)
|
||||
|
||||
console.log('Workflow saved, reloading with new UUIDs:', savedData)
|
||||
|
||||
// Close properties panel if open to avoid ID mismatch issues
|
||||
if (showPropertiesPanel.value) {
|
||||
closePropertiesPanel()
|
||||
}
|
||||
|
||||
// Clear current nodes and edges
|
||||
removeNodes(getNodes().map(n => n.id))
|
||||
|
||||
// Reload workflow data with new UUIDs from backend
|
||||
await loadWorkflowData()
|
||||
|
||||
toast.success('工作流已保存', {
|
||||
position: 'top-center'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error saving workflow:', error)
|
||||
toast.error('保存工作流失败', {
|
||||
position: 'top-center'
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Publish workflow
|
||||
const publishWorkflow = async () => {
|
||||
try {
|
||||
publishLoading.value = true
|
||||
|
||||
const nodes = getNodes()
|
||||
const edges = getEdges()
|
||||
|
||||
// Validate connections before publishing
|
||||
if (nodes.length > 1 && edges.length === 0) {
|
||||
toast.error('无法发布节点之间没有连接的工作流,请先连接节点再发布。', {
|
||||
duration: 5000,
|
||||
closeButton: true,
|
||||
position: 'top-center'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// For single node workflows, ensure it's a valid starting node type
|
||||
if (nodes.length === 1) {
|
||||
const singleNode = nodes[0]
|
||||
const validStartingNodeTypes = ['landingPage', 'message', 'llm', 'form']
|
||||
if (!validStartingNodeTypes.includes(singleNode.data.nodeType)) {
|
||||
toast.error(`工作流仅包含一个 ${singleNode.data.nodeType} 节点,无法发布。工作流必须以落地页、消息、LLM 或表单节点开始。`, {
|
||||
duration: 6000,
|
||||
closeButton: true,
|
||||
position: 'top-center'
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced connection validation: Check for isolated nodes
|
||||
if (nodes.length > 1) {
|
||||
const connectedNodeIds = new Set<string>()
|
||||
|
||||
edges.forEach(edge => {
|
||||
connectedNodeIds.add(edge.source)
|
||||
connectedNodeIds.add(edge.target)
|
||||
})
|
||||
|
||||
const isolatedNodes = nodes.filter(node => !connectedNodeIds.has(node.id))
|
||||
|
||||
if (isolatedNodes.length > 0) {
|
||||
const isolatedNodeNames = isolatedNodes.map(node =>
|
||||
node.data.cleanName || node.data.label || '未命名节点'
|
||||
).join(', ')
|
||||
|
||||
toast.error(`存在孤立节点,无法发布工作流:${isolatedNodeNames}。请先连接所有节点再发布。`, {
|
||||
duration: 6000,
|
||||
closeButton: true,
|
||||
position: 'top-center'
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate before publishing
|
||||
const validation = validateAllNodes(nodes, workflowId, edges)
|
||||
if (!validation.isValid) {
|
||||
highlightNodesWithErrors(nodes, workflowId, availableNodeTypes, edges)
|
||||
toast.error('发布前请先修正校验错误:\n' + validation.errors.join('\n'), {
|
||||
duration: 8000,
|
||||
closeButton: true,
|
||||
position: 'top-center'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// First save the workflow to ensure all changes are persisted
|
||||
await saveWorkflow()
|
||||
|
||||
// Then publish it
|
||||
const updatedWorkflow = await workflowService.publishWorkflow(workflowId)
|
||||
workflowStatus.value = updatedWorkflow.status
|
||||
|
||||
toast.success('工作流已发布!现在已上线,可以处理会话。', {
|
||||
position: 'top-center'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error publishing workflow:', error)
|
||||
toast.error('发布工作流失败', {
|
||||
position: 'top-center'
|
||||
})
|
||||
} finally {
|
||||
publishLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Unpublish workflow
|
||||
const unpublishWorkflow = async () => {
|
||||
try {
|
||||
publishLoading.value = true
|
||||
|
||||
const updatedWorkflow = await workflowService.unpublishWorkflow(workflowId)
|
||||
workflowStatus.value = updatedWorkflow.status
|
||||
|
||||
toast.success('工作流已取消发布!现在处于草稿模式。', {
|
||||
position: 'top-center'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error unpublishing workflow:', error)
|
||||
toast.error('取消发布工作流失败', {
|
||||
position: 'top-center'
|
||||
})
|
||||
} finally {
|
||||
publishLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle nodes deletion
|
||||
const handleNodesDelete = (deletedNodes: Node[]) => {
|
||||
console.log('handleNodesDelete called with:', deletedNodes)
|
||||
|
||||
// Remove deleted nodes from cache
|
||||
deletedNodes.forEach(node => {
|
||||
console.log('Deleting node from cache (handleNodesDelete):', node.id)
|
||||
workflowNodeService.deleteNodeFromCache(workflowId, node.id)
|
||||
})
|
||||
|
||||
// Check if the currently selected node was deleted
|
||||
if (selectedNode.value && deletedNodes.some(node => node.id === selectedNode.value?.id)) {
|
||||
console.log('Selected node was deleted, closing properties panel')
|
||||
closePropertiesPanel()
|
||||
toast.success('节点已删除', {
|
||||
position: 'top-center'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle nodes change (e.g., node added, moved, deleted)
|
||||
const handleNodesChange = (changes: any) => {
|
||||
console.log('Nodes changed:', changes)
|
||||
|
||||
changes.forEach((change: any) => {
|
||||
switch (change.type) {
|
||||
case 'remove':
|
||||
console.log('Node removal detected:', change)
|
||||
workflowNodeService.deleteNodeFromCache(workflowId, change.id)
|
||||
|
||||
if (selectedNode.value && selectedNode.value.id === change.id) {
|
||||
console.log('Selected node was deleted, closing properties panel')
|
||||
closePropertiesPanel()
|
||||
toast.success('节点已删除', {
|
||||
position: 'top-center'
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'position':
|
||||
if (change.position) {
|
||||
const nodeIndex = getNodes().findIndex(n => n.id === change.id)
|
||||
if (nodeIndex !== -1) {
|
||||
const node = getNodes()[nodeIndex]
|
||||
const cacheNodeData = {
|
||||
id: node.id,
|
||||
node_type: mapNodeTypeToBackend(node.data.nodeType),
|
||||
name: node.data.cleanName || node.data.label || 'Node',
|
||||
description: node.data.description || '',
|
||||
position_x: change.position.x,
|
||||
position_y: change.position.y,
|
||||
config: node.data.config || {}
|
||||
}
|
||||
workflowNodeService.updateNodeInCache(workflowId, cacheNodeData)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Handle delete event from Vue Flow
|
||||
const handleDeleteEvent = (event: any) => {
|
||||
console.log('Vue Flow delete event:', event)
|
||||
|
||||
const handleSingleNodeDeletion = (nodeId: string) => {
|
||||
console.log('Deleting node from cache (handleDeleteEvent):', nodeId)
|
||||
workflowNodeService.deleteNodeFromCache(workflowId, nodeId)
|
||||
|
||||
if (selectedNode.value && selectedNode.value.id === nodeId) {
|
||||
closePropertiesPanel()
|
||||
toast.success('节点已删除', {
|
||||
position: 'top-center'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(event)) {
|
||||
const deletedNodes = event.filter((item: any) => item.type === 'node' || !item.type)
|
||||
deletedNodes.forEach((node: any) => handleSingleNodeDeletion(node.id))
|
||||
} else if (event.nodes) {
|
||||
event.nodes.forEach((node: any) => handleSingleNodeDeletion(node.id))
|
||||
} else if (event.id) {
|
||||
handleSingleNodeDeletion(event.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle edge changes (including deletion)
|
||||
const handleEdgesChange = (changes: any[]) => {
|
||||
console.log('Edges changed:', changes)
|
||||
|
||||
changes.forEach((change: any) => {
|
||||
switch (change.type) {
|
||||
case 'remove':
|
||||
console.log('Edge removal detected:', change)
|
||||
workflowNodeService.removeConnectionFromCache(workflowId, change.id)
|
||||
|
||||
toast.success('连接已删除', {
|
||||
position: 'top-center'
|
||||
})
|
||||
break
|
||||
|
||||
// Handle other edge change types if needed in the future
|
||||
case 'select':
|
||||
// Edge selection - no cache update needed
|
||||
break
|
||||
|
||||
case 'add':
|
||||
// Edge addition - already handled in onConnect
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
loadWorkflowData,
|
||||
saveNodeProperties,
|
||||
saveWorkflow,
|
||||
publishWorkflow,
|
||||
unpublishWorkflow,
|
||||
handleNodesDelete,
|
||||
handleNodesChange,
|
||||
handleDeleteEvent,
|
||||
handleEdgesChange,
|
||||
mapNodeTypeToFrontend,
|
||||
mapNodeTypeToBackend
|
||||
}
|
||||
}
|
||||
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user