Files
geo/apps/admin-web/src/components/PromptRuleModal.vue
T
root e045e00fbf
Frontend CI / Frontend (push) Successful in 3m8s
Backend CI / Backend (push) Successful in 14m48s
feat(auth,prompt-rule): add password change with session revocation and scope prompt rules by brand
Add self-service password change that re-hashes the credential and bumps a
per-user session version so all existing access/refresh tokens are rejected.
Session version is tracked in Redis with an in-memory fallback and enforced in
middleware and refresh.

Scope prompt rules and rule groups to a brand: new brand_id column (migrated to
a "未归属" fallback brand for existing rows), brand-filtered queries/indexes, and
brand-aware admin-web tabs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:09:53 +08:00

324 lines
8.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { InfoCircleOutlined } from '@ant-design/icons-vue'
import type { PromptRule, PromptRuleGroup } from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-vue'
import { computed, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
import { promptRulesApi } from '@/lib/api'
import { formatError } from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
const props = defineProps<{
open: boolean
rule?: PromptRule | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
saved: [ruleId: number]
}>()
const queryClient = useQueryClient()
const { t } = useI18n()
const companyStore = useCompanyStore()
const form = reactive({
name: '',
prompt_content: '',
scene: '',
default_tone: '',
default_word_count: undefined as number | undefined,
group_id: undefined as number | undefined,
knowledge_group_ids: [] as number[],
})
const groupsQuery = useQuery({
queryKey: computed(() => ['promptRules', 'groups', companyStore.currentBrandId]),
enabled: computed(() => props.open && Boolean(companyStore.currentBrandId)),
queryFn: () => promptRulesApi.listGroups(),
})
watch(
() => props.open,
(open) => {
if (!open) {
return
}
const rule = props.rule
form.name = rule?.name ?? ''
form.prompt_content = rule?.prompt_content ?? ''
form.scene = rule?.scene ?? ''
form.default_tone = rule?.default_tone ?? ''
form.default_word_count = rule?.default_word_count ?? undefined
form.group_id = rule?.group_id ?? undefined
form.knowledge_group_ids = rule?.knowledge_group_ids ?? []
},
)
watch(
() => companyStore.currentBrandId,
() => {
if (props.open) {
emit('update:open', false)
}
},
)
const createMutation = useMutation({
mutationFn: () =>
promptRulesApi.create({
group_id: form.group_id ?? null,
name: form.name.trim(),
prompt_content: form.prompt_content.trim(),
scene: form.scene.trim() || undefined,
default_tone: form.default_tone.trim() || undefined,
default_word_count: form.default_word_count,
knowledge_group_ids: form.knowledge_group_ids,
}),
onSuccess: async (data) => {
message.success(t('custom.messages.ruleCreated'))
emit('saved', data.id)
emit('update:open', false)
await queryClient.invalidateQueries({ queryKey: ['promptRules'] })
},
onError: (error) => message.error(formatError(error)),
})
const updateMutation = useMutation({
mutationFn: () =>
promptRulesApi.update(props.rule!.id, {
group_id: form.group_id ?? null,
name: form.name.trim(),
prompt_content: form.prompt_content.trim(),
scene: form.scene.trim() || undefined,
default_tone: form.default_tone.trim() || undefined,
default_word_count: form.default_word_count,
knowledge_group_ids: form.knowledge_group_ids,
}),
onSuccess: async () => {
message.success(t('custom.messages.ruleUpdated'))
if (props.rule?.id) {
emit('saved', props.rule.id)
}
emit('update:open', false)
await queryClient.invalidateQueries({ queryKey: ['promptRules'] })
},
onError: (error) => message.error(formatError(error)),
})
const submitLoading = computed(
() => createMutation.isPending.value || updateMutation.isPending.value,
)
const drawerTitle = computed(() =>
props.rule?.id ? t('custom.promptRule.editTitle') : t('custom.promptRule.createTitle'),
)
async function handleSubmit(): Promise<void> {
if (!form.name.trim()) {
message.warning(t('custom.messages.missingPromptName'))
return
}
if (!form.prompt_content.trim()) {
message.warning(t('custom.messages.missingPromptContent'))
return
}
if (props.rule?.id) {
await updateMutation.mutateAsync()
return
}
await createMutation.mutateAsync()
}
</script>
<template>
<a-drawer
:open="open"
:title="drawerTitle"
placement="right"
width="860"
:mask-closable="false"
class="prompt-rule-drawer"
@close="emit('update:open', false)"
>
<div class="prompt-rule-drawer__body">
<div class="prompt-rule-drawer__field">
<label class="prompt-rule-drawer__label prompt-rule-drawer__label--required">
{{ t('custom.promptRule.name') }}
</label>
<a-input
v-model:value="form.name"
size="large"
:maxlength="100"
:placeholder="t('custom.promptRule.namePlaceholder')"
/>
</div>
<div class="prompt-rule-drawer__field">
<label class="prompt-rule-drawer__label prompt-rule-drawer__label--required">
{{ t('custom.promptRule.content') }}
</label>
<a-textarea
v-model:value="form.prompt_content"
:rows="12"
:placeholder="t('custom.promptRule.contentPlaceholder')"
/>
</div>
<div class="prompt-rule-drawer__field">
<label class="prompt-rule-drawer__label">{{ t('custom.group.title') }}</label>
<a-select
v-model:value="form.group_id"
size="large"
style="width: 100%"
:placeholder="t('custom.promptRule.groupPlaceholder')"
:options="
(groupsQuery.data.value ?? []).map((group: PromptRuleGroup) => ({
label: group.name,
value: group.id,
}))
"
allow-clear
/>
</div>
<div class="prompt-rule-drawer__grid">
<div class="prompt-rule-drawer__field">
<label class="prompt-rule-drawer__label">{{ t('custom.promptRule.scene') }}</label>
<a-input v-model:value="form.scene" size="large" />
</div>
<div class="prompt-rule-drawer__field">
<label class="prompt-rule-drawer__label">{{ t('custom.promptRule.tone') }}</label>
<a-input v-model:value="form.default_tone" size="large" />
</div>
<div class="prompt-rule-drawer__field">
<label class="prompt-rule-drawer__label">
{{ t('custom.promptRule.wordCount') }}
<a-tooltip :title="t('custom.promptRule.wordCountHint')">
<InfoCircleOutlined style="margin-left: 6px; color: #8c8c8c; cursor: help" />
</a-tooltip>
</label>
<a-input-number
v-model:value="form.default_word_count"
:min="100"
:max="6000"
style="width: 100%"
size="large"
/>
</div>
</div>
<div class="prompt-rule-drawer__field">
<label class="prompt-rule-drawer__label">知识库引用</label>
<KnowledgeGroupSelect
v-model="form.knowledge_group_ids"
placeholder="可选,用于补充生成内容的背景信息"
/>
</div>
</div>
<template #footer>
<div class="prompt-rule-drawer__footer">
<a-button size="large" @click="emit('update:open', false)">
{{ t('common.cancel') }}
</a-button>
<a-button type="primary" size="large" :loading="submitLoading" @click="handleSubmit">
{{ t('common.confirm') }}
</a-button>
</div>
</template>
</a-drawer>
</template>
<style scoped>
.prompt-rule-drawer :deep(.ant-drawer-header) {
padding: 30px 28px;
border-bottom: 1px solid #edf2f7;
}
.prompt-rule-drawer :deep(.ant-drawer-title) {
color: #111827;
font-size: 18px;
font-weight: 800;
}
.prompt-rule-drawer :deep(.ant-drawer-body) {
padding: 30px 32px 32px;
background: #fff;
}
.prompt-rule-drawer :deep(.ant-drawer-footer) {
padding: 18px 28px;
border-top: 1px solid #edf2f7;
background: #fff;
}
.prompt-rule-drawer__body {
padding: 0 20px;
display: flex;
flex-direction: column;
}
.prompt-rule-drawer__field {
margin-top: 24px;
}
.prompt-rule-drawer__field:first-child {
margin-top: 0;
}
.prompt-rule-drawer__grid .prompt-rule-drawer__field {
margin-top: 0;
}
.prompt-rule-drawer__field :deep(.ant-input),
.prompt-rule-drawer__field :deep(.ant-input-number),
.prompt-rule-drawer__field :deep(.ant-select-selector),
.prompt-rule-drawer__field :deep(textarea.ant-input) {
margin-top: 4px;
}
.prompt-rule-drawer__label {
display: inline-flex;
align-items: center;
margin-bottom: 12px;
color: #1f2937;
font-size: 15px;
font-weight: 700;
}
.prompt-rule-drawer__label--required::before {
content: '*';
margin-right: 4px;
color: #ff4d4f;
}
.prompt-rule-drawer__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px 18px;
margin-top: 24px;
}
.prompt-rule-drawer__footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 820px) {
.prompt-rule-drawer__grid {
grid-template-columns: 1fr;
}
}
</style>