feat(kol): expand variable schema and polish KOL UI

- Add length/range limits to KOL variable definitions: max_length and
  default_value for input/textarea, min_value/max_value/default_number
  for number. Backend validates and normalizes; frontend renders limit
  inputs in KolVariableConfig and applies limits at runtime in
  KolDynamicForm.
- Sort workspace cards by most recent prompt/package update, falling
  back to granted_at. Adds updated_at to KolWorkspaceCard.
- Polish TemplatesView, WorkspaceView, and KOL package management UI;
  route create/update/publish/archive/delete toasts through i18n and
  expand package form copy and status labels.
- Remove stale KnowledgeView.vue.patch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 15:01:40 +08:00
parent 1dd316a34b
commit 79c65c1da7
26 changed files with 1506 additions and 235 deletions
@@ -1,8 +1,14 @@
<script setup lang="ts">
import { reactive, watch } from "vue";
import { DeleteOutlined, PictureOutlined } from "@ant-design/icons-vue";
import { message } from "ant-design-vue";
import { computed, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { CreateKolPackageRequest } from "@geo/shared-types";
import CoverPickerModal from "@/components/CoverPickerModal.vue";
import { imagesApi, resolveApiURL } from "@/lib/api";
import { deriveCoverFileName } from "@/lib/cover-requirements";
const props = defineProps<{
open: boolean;
initialValue?: Partial<CreateKolPackageRequest>;
@@ -14,6 +20,7 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const coverPickerOpen = ref(false);
const formState = reactive<CreateKolPackageRequest>({
name: "",
@@ -32,29 +39,57 @@ watch(
name: props.initialValue?.name || "",
description: props.initialValue?.description || "",
industry: props.initialValue?.industry || "",
tags: props.initialValue?.tags || [],
tags: [...(props.initialValue?.tags || [])],
price_note: props.initialValue?.price_note || "",
cover_url: props.initialValue?.cover_url || "",
});
return;
}
}
coverPickerOpen.value = false;
},
);
const coverPreviewUrl = computed(() => resolveApiURL(formState.cover_url));
const coverFileName = computed(() => deriveCoverFileName(formState.cover_url));
function handleOk() {
if (!formState.name.trim()) return;
emit("submit", { ...formState });
if (!formState.name.trim()) {
message.warning(t("kol.manage.packageForm.nameRequired"));
return;
}
emit("submit", { ...formState, tags: [...formState.tags] });
emit("update:open", false);
}
function handleCancel() {
emit("update:open", false);
}
function handleRemoveCover() {
formState.cover_url = "";
}
function handleCoverConfirmed(payload: { url: string }) {
formState.cover_url = resolveApiURL(payload.url);
}
async function uploadCover(file: File) {
const uploaded = await imagesApi.upload(file);
return {
url: uploaded.url,
fileName: uploaded.name,
assetId: uploaded.id,
};
}
</script>
<template>
<a-modal
:open="open"
:title="initialValue ? t('kol.manage.editPackage') : t('kol.manage.createPackage')"
:ok-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
@ok="handleOk"
@cancel="handleCancel"
>
@@ -71,17 +106,132 @@ function handleCancel() {
<a-input v-model:value="formState.industry" />
</a-form-item>
<a-form-item label="Tags">
<a-select v-model:value="formState.tags" mode="tags" style="width: 100%" placeholder="Add tags" />
<a-form-item :label="t('kol.manage.packageForm.tags')">
<a-select
v-model:value="formState.tags"
mode="tags"
style="width: 100%"
:placeholder="t('kol.manage.packageForm.tagsPlaceholder')"
/>
</a-form-item>
<a-form-item label="Price Note">
<a-input v-model:value="formState.price_note" />
<a-form-item :label="t('kol.manage.packageForm.priceNote')">
<a-input
v-model:value="formState.price_note"
:placeholder="t('kol.manage.packageForm.priceNotePlaceholder')"
/>
</a-form-item>
<a-form-item label="Cover URL">
<a-input v-model:value="formState.cover_url" />
<a-form-item :label="t('kol.manage.packageForm.cover')">
<div class="package-cover-field">
<button
type="button"
class="package-cover-picker"
@click="coverPickerOpen = true"
>
<template v-if="coverPreviewUrl">
<img :src="coverPreviewUrl" :alt="t('kol.manage.packageForm.cover')" class="package-cover-picker__image" />
</template>
<template v-else>
<div class="package-cover-picker__empty">
<PictureOutlined class="package-cover-picker__icon" />
<strong>{{ t("kol.manage.packageForm.selectCover") }}</strong>
<span>{{ t("kol.manage.packageForm.coverHint") }}</span>
</div>
</template>
</button>
<div class="package-cover-actions">
<a-button @click="coverPickerOpen = true">
{{ coverPreviewUrl ? t("kol.manage.packageForm.changeCover") : t("kol.manage.packageForm.selectCover") }}
</a-button>
<a-button v-if="coverPreviewUrl" danger @click="handleRemoveCover">
<template #icon><DeleteOutlined /></template>
{{ t("kol.manage.packageForm.removeCover") }}
</a-button>
</div>
</div>
</a-form-item>
</a-form>
</a-modal>
<CoverPickerModal
v-model:open="coverPickerOpen"
:article-id="null"
:platform-ids="[]"
:current-url="coverPreviewUrl"
:current-file-name="coverFileName"
:title="t('kol.manage.packageForm.coverPicker.title')"
:local-tab-label="t('kol.manage.packageForm.coverPicker.localTab')"
:empty-upload-title="t('kol.manage.packageForm.coverPicker.emptyTitle')"
:empty-upload-hint="t('kol.manage.packageForm.coverPicker.emptyHint')"
:upload-handler="uploadCover"
@confirmed="handleCoverConfirmed"
/>
</template>
<style scoped>
.package-cover-field {
display: flex;
flex-direction: column;
gap: 12px;
}
.package-cover-picker {
width: 100%;
min-height: 180px;
border: 1px dashed #d0d5dd;
border-radius: 12px;
padding: 0;
overflow: hidden;
background: #f8fafc;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.package-cover-picker:hover {
border-color: #1677ff;
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.08);
}
.package-cover-picker__image {
display: block;
width: 100%;
height: 180px;
object-fit: cover;
}
.package-cover-picker__empty {
display: flex;
min-height: 180px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px;
color: #475467;
text-align: center;
}
.package-cover-picker__empty strong {
color: #111827;
font-size: 15px;
font-weight: 700;
}
.package-cover-picker__empty span {
max-width: 320px;
font-size: 12px;
line-height: 1.6;
}
.package-cover-picker__icon {
font-size: 28px;
color: #1677ff;
}
.package-cover-actions {
display: flex;
gap: 8px;
}
</style>