修改下拉

This commit is contained in:
ZuoZuo 2026-04-15 11:13:51 +08:00
parent 877f4f04fe
commit 4b24e94041
11 changed files with 1666 additions and 66 deletions

View File

@ -214,6 +214,12 @@ export async function getUserBaseInfo(userId: number | string) {
return requestClient.get<Record<string, any>>(`/user/base/info/${userId}`);
}
export async function getUserPhotoWallNormal(userId: number | string) {
return requestClient.get<Record<string, any>[]>(
`/user/photo/wall/normal/${userId}`,
);
}
export async function getUserPhotoWallAll(userId: number | string) {
return requestClient.get<string[]>(`/user/photo/wall/all/${userId}`);
}

View File

@ -125,6 +125,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
export const requestClient = createRequestClient(apiURL, {
responseReturn: 'data',
timeout: 60_000,
});
export const baseRequestClient = requestClient;

View File

@ -16,7 +16,6 @@ import {
Input,
Modal,
Select,
SelectOption,
Space,
TextArea,
message,
@ -71,6 +70,16 @@ const actionOptions = computed(() => {
);
return filtered.length > 0 ? filtered : items;
});
const actionSelectOptions = computed(() =>
actionOptions.value.map((item) => ({
label: `${item.group} / ${item.label}`,
value: item.value,
})),
);
const dayOptions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 30].map((day) => ({
label: `${day}`,
value: day,
}));
const form = reactive({
accountStatusEnum: '',
@ -176,25 +185,22 @@ async function handleSubmit() {
<Input :value="currentStatus" disabled />
</FormItem>
<FormItem label="处理状态">
<Select option-label-prop="children" v-model:value="form.accountStatusEnum" :disabled="loading">
<SelectOption
v-for="item in actionOptions"
:key="item.value"
:value="item.value"
>
{{ item.group }} / {{ item.label }}
</SelectOption>
</Select>
<Select
v-model:value="form.accountStatusEnum"
:disabled="loading"
:options="actionSelectOptions"
option-label-prop="label"
/>
</FormItem>
<FormItem
v-if="form.accountStatusEnum === 'FREEZE' || form.accountStatusEnum === 'ARCHIVE' || form.accountStatusEnum === 'ARCHIVE1DAY'"
label="天数"
>
<Select option-label-prop="children" v-model:value="form.days">
<SelectOption v-for="day in [1,2,3,4,5,6,7,8,9,10,15,30]" :key="day" :value="day">
{{ day }}
</SelectOption>
</Select>
<Select
v-model:value="form.days"
:options="dayOptions"
option-label-prop="label"
/>
</FormItem>
<FormItem label="备注">
<TextArea v-model:value="form.description" :rows="4" />

View File

@ -7,7 +7,7 @@ import { Page } from '@vben/common-ui';
import {
getUserAccountStatus,
getUserBaseInfo,
getUserPhotoWallAll,
getUserPhotoWallNormal,
getUserRegisterInfo,
} from '#/api/legacy/approval';
@ -64,7 +64,7 @@ watch(
getUserBaseInfo(id),
getUserRegisterInfo(id),
getUserAccountStatus(id),
getUserPhotoWallAll(id),
getUserPhotoWallNormal(id),
]);
baseInfo.value = base || {};
registerInfo.value = register || {};

View File

@ -0,0 +1,209 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import ActivityTemplateLangEditor from './activity-template-lang-editor.vue';
import { cloneTemplateValue } from './activity-template-shared';
import {
Empty,
Form,
FormItem,
Input,
Select,
Tabs,
TabPane,
} from 'antdv-next';
const props = defineProps<{
form: null | Record<string, any>;
}>();
const emit = defineEmits<{
change: [Record<string, any>];
}>();
const syncing = ref(false);
const formState = ref<null | Record<string, any>>(null);
const activeTab = ref('attr');
const backgroundColorDirections = [
{ label: '无', value: 'NONE' },
{ label: '从左到右渐变', value: 'to right' },
{ label: '从右到左渐变', value: 'to left' },
{ label: '从下到上渐变', value: 'to top' },
{ label: '从上到下渐变', value: 'to bottom' },
{ label: '从左对角渐变', value: 'to bottom right' },
{ label: '从右对角渐变', value: 'to bottom left' },
];
const backgroundImgRepeats = [
{ label: 'repeat', value: 'repeat' },
{ label: 'repeat-x', value: 'repeat-x' },
{ label: 'repeat-y', value: 'repeat-y' },
{ label: 'no-repeat', value: 'no-repeat' },
{ label: 'inherit', value: 'inherit' },
];
const attrForm = computed(() => formState.value?.attr || {});
const styleForm = computed(() => formState.value?.style || {});
watch(
() => props.form,
(value) => {
syncing.value = true;
formState.value = value ? cloneTemplateValue(value) : null;
Promise.resolve().then(() => {
syncing.value = false;
});
},
{ deep: true, immediate: true },
);
watch(
formState,
(value) => {
if (syncing.value || !value) {
return;
}
emit('change', cloneTemplateValue(value));
},
{ deep: true },
);
function hasPath(value: Record<string, any>, path: string) {
if (!value || !path) {
return false;
}
const keys = path.split('.').filter(Boolean);
let current: any = value;
for (const key of keys) {
if (current == null || !(key in current)) {
return false;
}
current = current[key];
}
return true;
}
</script>
<template>
<div class="attr-editor">
<Empty v-if="!formState" description="请选择模板节点" />
<Tabs v-else v-model:activeKey="activeTab" size="small">
<TabPane key="attr" tab="属性">
<Form layout="vertical">
<FormItem v-if="attrForm.id" label="ID">
<Input :value="String(attrForm.id || '')" disabled />
</FormItem>
<FormItem v-if="attrForm.label" label="组件名称">
<Input :value="String(attrForm.label || '')" disabled />
</FormItem>
<FormItem
v-if="hasPath(attrForm, 'copywriting')"
:label="String(attrForm.copywriting?.label || '文案')"
>
<ActivityTemplateLangEditor
v-model:modelValue="attrForm.copywriting.i18n"
:type="attrForm.copywriting?.type"
/>
</FormItem>
<template v-if="Array.isArray(attrForm.imgUrls)">
<FormItem
v-for="(item, index) in attrForm.imgUrls"
:key="`${item.id || index}`"
:label="item.label || `图片${Number(index) + 1}`"
>
<Input v-model:value="item.url" />
</FormItem>
</template>
</Form>
</TabPane>
<TabPane v-if="!styleForm.hide" key="style" tab="样式">
<div v-if="styleForm.tips" class="style-tips">
{{ styleForm.tips }}
</div>
<Form layout="vertical">
<div class="section-title">背景</div>
<FormItem v-if="hasPath(styleForm, 'background.img.url')" label="背景图">
<Input v-model:value="styleForm.background.img.url" />
</FormItem>
<FormItem v-if="hasPath(styleForm, 'background.img.repeat')" label="背景图重复">
<Select
v-model:value="styleForm.background.img.repeat"
allow-clear
:options="backgroundImgRepeats"
/>
</FormItem>
<FormItem v-if="hasPath(styleForm, 'background.img.size')" label="背景图大小">
<Input v-model:value="styleForm.background.img.size" placeholder="如 100% 100%" />
</FormItem>
<FormItem v-if="hasPath(styleForm, 'background.color.start')" label="背景起始色">
<Input v-model:value="styleForm.background.color.start" placeholder="#FFFFFF" />
</FormItem>
<FormItem v-if="hasPath(styleForm, 'background.color.direction')" label="渐变方向">
<Select v-model:value="styleForm.background.color.direction" :options="backgroundColorDirections" />
</FormItem>
<FormItem
v-if="
hasPath(styleForm, 'background.color.end')
&& styleForm.background?.color?.direction !== 'NONE'
"
label="背景结束色"
>
<Input v-model:value="styleForm.background.color.end" placeholder="#000000" />
</FormItem>
<div class="section-title">字体</div>
<FormItem v-if="hasPath(styleForm, 'font.color')" label="字体颜色">
<Input v-model:value="styleForm.font.color" placeholder="#FFFFFF" />
</FormItem>
<FormItem v-if="hasPath(styleForm, 'font.size')" label="字体大小">
<Input v-model:value="styleForm.font.size" placeholder=".35rem" />
</FormItem>
<FormItem v-if="hasPath(styleForm, 'font.weight')" label="字体粗细">
<Input v-model:value="styleForm.font.weight" placeholder="bold / 600" />
</FormItem>
<div class="section-title">边框</div>
<FormItem v-if="hasPath(styleForm, 'border.width')" label="边框宽度">
<Input v-model:value="styleForm.border.width" placeholder=".02rem" />
</FormItem>
<FormItem v-if="hasPath(styleForm, 'border.style')" label="边框样式">
<Input v-model:value="styleForm.border.style" placeholder="solid" />
</FormItem>
<FormItem v-if="hasPath(styleForm, 'border.color')" label="边框颜色">
<Input v-model:value="styleForm.border.color" placeholder="#FFFFFF" />
</FormItem>
<FormItem v-if="hasPath(styleForm, 'border.radius')" label="边框圆角">
<Input v-model:value="styleForm.border.radius" placeholder=".1rem" />
</FormItem>
</Form>
</TabPane>
</Tabs>
</div>
</template>
<style scoped>
.attr-editor {
min-height: 420px;
}
.style-tips {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 10px;
color: #1d4ed8;
margin-bottom: 12px;
padding: 10px 12px;
}
.section-title {
color: #0f172a;
font-size: 13px;
font-weight: 600;
margin-bottom: 12px;
}
</style>

View File

@ -6,15 +6,26 @@ import {
updateTemplate,
} from '#/api/legacy/system';
import ActivityTemplateAttrEditor from './activity-template-attr-editor.vue';
import {
buildTemplateNodeForm,
cloneTemplateValue,
findTemplateNode,
getFirstTemplateNodeId,
giftRankingTreeIndexs,
normalizeTemplateTree,
} from './activity-template-shared';
import {
Button,
Descriptions,
DescriptionsItem,
Drawer,
Empty,
Modal,
Space,
Spin,
TextArea,
Tree,
message,
} from 'antdv-next';
@ -33,7 +44,8 @@ const emit = defineEmits<{
const loading = ref(false);
const saving = ref(false);
const template = ref<Record<string, any> | null>(null);
const jsonText = ref('');
const indexTree = ref<Array<Record<string, any>>>([]);
const selectedNodeId = ref('');
const title = computed(() => {
if (!props.templateId) {
@ -42,6 +54,20 @@ const title = computed(() => {
return `编辑模版 #${props.templateId}`;
});
const treeData = computed(() => {
const convert = (list: Array<Record<string, any>>): Array<Record<string, any>> =>
list.map((item) => ({
children: convert(item.children || []),
key: item.id,
title: `${item.label || item.id} (${item.id})`,
}));
return convert(indexTree.value);
});
const selectedNode = computed(() => findTemplateNode(indexTree.value, selectedNodeId.value));
const selectedNodeForm = computed(() => buildTemplateNodeForm(selectedNode.value));
watch(
() => (props.open ? props.templateId : ''),
async (templateId) => {
@ -51,8 +77,17 @@ watch(
loading.value = true;
try {
const result = await getTemplateById(templateId);
template.value = result || {};
jsonText.value = JSON.stringify(template.value?.indexTree || [], null, 2);
const nextTree = normalizeTemplateTree(
Array.isArray(result?.indexTree) && result.indexTree.length > 0
? result.indexTree
: giftRankingTreeIndexs,
);
template.value = {
...(result || {}),
indexTree: nextTree,
};
indexTree.value = nextTree;
selectedNodeId.value = getFirstTemplateNodeId(nextTree);
} finally {
loading.value = false;
}
@ -64,28 +99,17 @@ async function handleSave() {
if (!template.value) {
return;
}
let indexTree: any[] = [];
try {
const parsed = JSON.parse(jsonText.value || '[]');
if (!Array.isArray(parsed)) {
message.warning('indexTree 必须是数组');
return;
}
indexTree = parsed;
} catch {
message.warning('JSON 格式不正确');
return;
}
saving.value = true;
try {
const nextTree = cloneTemplateValue(indexTree.value || []);
await updateTemplate({
...template.value,
indexTree,
indexTree: nextTree,
});
template.value = {
...template.value,
indexTree,
indexTree: nextTree,
};
message.success('保存成功');
emit('success');
@ -93,6 +117,30 @@ async function handleSave() {
saving.value = false;
}
}
function handleSelect(keys: Array<string | number>) {
selectedNodeId.value = String(keys?.[0] || '');
}
function handleEditorChange(form: Record<string, any>) {
const node = selectedNode.value;
if (!node) {
return;
}
node.editor = cloneTemplateValue(form);
}
function handleResetDefault() {
Modal.confirm({
title: '确认恢复默认模板树吗?',
content: '会覆盖当前抽屉里未保存的模板结构。',
onOk() {
const nextTree = normalizeTemplateTree(giftRankingTreeIndexs);
indexTree.value = nextTree;
selectedNodeId.value = getFirstTemplateNodeId(nextTree);
},
});
}
</script>
<template>
@ -118,16 +166,33 @@ async function handleSave() {
</Descriptions>
<div class="tips">
<div>原项目这里编辑的是模版 `indexTree` 结构</div>
<div>当前保持同一份 JSON 结构读写不改动后台字段定义</div>
<div>新后台已恢复为树形模板编辑</div>
<div>左侧选择节点右侧编辑属性和样式保存时仍然写回同一份 `indexTree` 结构</div>
</div>
<TextArea
v-model:value="jsonText"
:auto-size="{ minRows: 24, maxRows: 36 }"
class="json-editor"
spellcheck="false"
/>
<div class="toolbar">
<Button @click="handleResetDefault">恢复默认模板树</Button>
</div>
<div class="workspace">
<div class="tree-panel">
<div class="panel-title">模板树</div>
<Tree
:selected-keys="selectedNodeId ? [selectedNodeId] : []"
:tree-data="treeData"
block-node
default-expand-all
@select="handleSelect"
/>
</div>
<div class="form-panel">
<div class="panel-title">
{{ selectedNode ? `${selectedNode.label || '-'} (${selectedNode.id || '-'})` : '节点属性' }}
</div>
<ActivityTemplateAttrEditor :form="selectedNodeForm" @change="handleEditorChange" />
</div>
</div>
</div>
<Empty v-else description="未获取到模版数据" />
</Spin>
@ -154,6 +219,11 @@ async function handleSave() {
gap: 16px;
}
.toolbar {
display: flex;
justify-content: flex-end;
}
.tips {
background: #f8fafc;
border: 1px solid #e2e8f0;
@ -163,14 +233,28 @@ async function handleSave() {
padding: 12px 14px;
}
.json-editor {
font-family:
'SFMono-Regular',
'Menlo',
'Monaco',
'Consolas',
'Liberation Mono',
'Courier New',
monospace;
.workspace {
display: grid;
gap: 16px;
grid-template-columns: 320px minmax(0, 1fr);
min-height: 560px;
}
.tree-panel,
.form-panel {
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 16px;
}
.tree-panel {
overflow: auto;
}
.panel-title {
color: #0f172a;
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
}
</style>

View File

@ -0,0 +1,134 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import {
cloneTemplateValue,
langs,
} from './activity-template-shared';
import {
Button,
Input,
Select,
TextArea,
} from 'antdv-next';
const props = defineProps<{
modelValue?: Array<Record<string, any>>;
type?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [Array<Record<string, any>>];
}>();
const syncing = ref(false);
const langList = ref<Array<Record<string, any>>>([]);
const pendingLang = ref<string>('');
const availableOptions = computed(() =>
langs
.filter((item) => !langList.value.some((langItem) => langItem.lang === item.value))
.map((item) => ({
label: item.name,
value: item.value,
})),
);
watch(
() => props.modelValue,
(value) => {
syncing.value = true;
langList.value = cloneTemplateValue(value || []);
Promise.resolve().then(() => {
syncing.value = false;
});
},
{ deep: true, immediate: true },
);
watch(
langList,
(value) => {
if (syncing.value) {
return;
}
emit('update:modelValue', cloneTemplateValue(value || []));
},
{ deep: true },
);
function addLang() {
if (!pendingLang.value) {
return;
}
const matched = langs.find((item) => item.value === pendingLang.value);
if (!matched) {
return;
}
langList.value.push({
lang: matched.value,
name: matched.name,
value: '',
});
pendingLang.value = '';
}
function removeLang(index: number) {
langList.value.splice(index, 1);
}
</script>
<template>
<div class="lang-editor">
<div v-for="(item, index) in langList" :key="`${item.lang}-${index}`" class="lang-item">
<div class="lang-item__head">
<span>{{ item.name || item.lang }}</span>
<Button danger size="small" type="link" @click="removeLang(index)">
删除
</Button>
</div>
<TextArea
v-if="type === 'textarea'"
v-model:value="item.value"
:rows="4"
spellcheck="false"
/>
<Input v-else v-model:value="item.value" />
</div>
<div v-if="availableOptions.length > 0" class="lang-editor__toolbar">
<Select
v-model:value="pendingLang"
allow-clear
placeholder="添加语言"
style="width: 180px"
:options="availableOptions"
/>
<Button @click="addLang">添加</Button>
</div>
</div>
</template>
<style scoped>
.lang-editor {
display: grid;
gap: 12px;
}
.lang-item {
display: grid;
gap: 8px;
}
.lang-item__head {
align-items: center;
display: flex;
justify-content: space-between;
}
.lang-editor__toolbar {
display: flex;
gap: 8px;
}
</style>

View File

@ -0,0 +1,26 @@
export declare const langs: Array<{ name: string; value: string }>;
export declare const giftRankingTreeIndexs: Array<Record<string, any>>;
export declare function cloneTemplateValue<T>(value: T): T;
export declare function createEditorForm(
options?: Record<string, any>,
): Record<string, any>;
export declare function normalizeTemplateTree(
tree?: Array<Record<string, any>>,
): Array<Record<string, any>>;
export declare function findTemplateNode(
tree: Array<Record<string, any>>,
nodeId: string,
): null | Record<string, any>;
export declare function getFirstTemplateNodeId(
tree?: Array<Record<string, any>>,
): string;
export declare function buildTemplateNodeForm(
node: null | Record<string, any>,
): null | Record<string, any>;

File diff suppressed because it is too large Load Diff

View File

@ -39,6 +39,7 @@ const form = reactive({
amount: undefined as number | undefined,
reasonType: 5,
remarks: '',
usdAmount: undefined as number | undefined,
});
const rewardReasonOptions = [
{ label: '奖励', value: 1 as any },
@ -56,6 +57,15 @@ const deductReasonOptions = [
const reasonTypeOptions = computed(() =>
props.action === 'reward' ? rewardReasonOptions : deductReasonOptions,
);
const selectedReasonLabel = computed(() => {
const matched = reasonTypeOptions.value.find(
(item) => item.value === form.reasonType,
);
return matched?.label ?? '';
});
const needsUsdAmount = computed(
() => props.type === 'gold' && props.action === 'reward' && [3, 4].includes(form.reasonType),
);
const title = computed(() => {
const typeName =
@ -73,10 +83,17 @@ watch(
form.amount = undefined;
form.reasonType = props.action === 'reward' ? 5 : 4;
form.remarks = '';
form.usdAmount = undefined;
},
{ immediate: true },
);
function buildRemarks() {
const reasonLabel = selectedReasonLabel.value;
const remarks = form.remarks.trim();
return reasonLabel ? (remarks ? `${reasonLabel}/${remarks}` : reasonLabel) : remarks;
}
async function handleSubmit() {
if (!props.userId) {
return;
@ -89,25 +106,33 @@ async function handleSubmit() {
message.warning('请输入备注');
return;
}
if (needsUsdAmount.value && (!form.usdAmount || form.usdAmount <= 0)) {
message.warning('请输入大于 0 的美金数量');
return;
}
if (props.type === 'gold') {
const payload = {
reason: form.reasonType,
remarks: form.remarks.trim(),
userId: props.userId,
value: form.amount,
};
const remarks = buildRemarks();
if (props.action === 'reward') {
await sendGold(payload);
await sendGold({
quantity: form.amount,
remarks,
type: form.reasonType,
usdQuantity: needsUsdAmount.value ? form.usdAmount : undefined,
userId: props.userId,
});
} else {
await deductGold(payload);
await deductGold({
quantity: form.amount,
remarks,
userId: props.userId,
});
}
} else if (props.type === 'diamond') {
const payload = {
reason: form.reasonType,
remarks: form.remarks.trim(),
quantity: form.amount,
remarks: buildRemarks(),
userId: props.userId,
value: form.amount,
};
if (props.action === 'reward') {
await sendDiamond(payload);
@ -117,14 +142,14 @@ async function handleSubmit() {
} else if (props.action === 'reward') {
await rewardGameCoupon({
coupon: form.amount,
remarks: form.remarks.trim(),
remarks: buildRemarks(),
rewardType: form.reasonType,
userId: props.userId,
});
} else {
await deductGameCoupon({
coupon: form.amount,
remarks: form.remarks.trim(),
remarks: buildRemarks(),
userId: props.userId,
});
}
@ -155,6 +180,14 @@ async function handleSubmit() {
style="width: 100%"
/>
</FormItem>
<FormItem v-if="needsUsdAmount" label="美金数量">
<InputNumber
v-model:value="form.usdAmount"
:min="0.01"
:precision="2"
style="width: 100%"
/>
</FormItem>
<FormItem label="原因">
<Select
option-label-prop="label"

View File

@ -63,7 +63,7 @@ class RequestClient {
},
responseReturn: 'raw',
// 默认超时时间
timeout: 10_000,
timeout: 60_000,
};
const { ...axiosConfig } = options;
const requestConfig = merge(axiosConfig, defaultConfig);