更新若干功能
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

This commit is contained in:
2026-08-20 19:18:39 +08:00
parent 6e69f13a75
commit 0cdfb7e956
89 changed files with 8133 additions and 872 deletions

View File

@@ -47,6 +47,7 @@
"@vueuse/core": "catalog:",
"ant-design-vue": "catalog:",
"dayjs": "catalog:",
"exceljs": "^4.4.0",
"jszip": "^3.10.1",
"markdown-it": "^15.0.0",
"pinia": "catalog:",

View File

@@ -21,6 +21,9 @@ async function initSetupVbenForm() {
Radio: 'checked',
Switch: 'checked',
Upload: 'fileList',
// 自研上传组件用 v-modelmodelValue不跟 antd 的 value 走
UploadImage: 'modelValue',
UploadDoc: 'modelValue',
},
},
rules: {

View File

@@ -19,7 +19,9 @@ interface CreateGridOptions {
* 列表页表格配置的公共部分。
* 各模块的 table.ts 只写自己的列与请求,其余(勾选、工具栏、高度、代理)统一在这里。
*/
export function createGridOptions(options: CreateGridOptions): VxeGridProps<any> {
export function createGridOptions(
options: CreateGridOptions,
): VxeGridProps<any> {
const {
columns,
extra = {},
@@ -56,12 +58,15 @@ export function createGridOptions(options: CreateGridOptions): VxeGridProps<any>
showOverflow: false,
toolbarConfig: {
custom: { icon: 'vxe-icon-menu' },
// 必须显式打开:适配器在找不到 #toolbar-actions 时会先写成 enabled=false深合并后会把整栏关掉
enabled: true,
export: false,
print: false,
refresh: true,
// @ts-ignore 适配器类型未声明 search
// @ts-expect-error 适配器类型未声明 search
search: true,
slots: { buttons: 'toolbar-buttons' },
// Vben 5.7 只认 toolbar-actions页面插槽必须同名
slots: { buttons: 'toolbar-actions' },
zoom: true,
},
};

View File

@@ -11,10 +11,19 @@ export function toImageList(value?: null | string): string[] {
return value ? [value] : [];
}
/** 取图片数组第一项,空数组转空串 */
export function firstImage(value?: null | string | string[]): string {
/** 取图片数组第一项,空数组转空串;对象则取 url */
export function firstImage(
value?: null | Record<string, any> | string | string[],
): string {
if (Array.isArray(value)) {
return value[0] ?? '';
const first = value[0];
if (first && typeof first === 'object') {
return String(first.url || first.uid || '');
}
return (first as string) ?? '';
}
if (value && typeof value === 'object') {
return String(value.url || '');
}
return value ?? '';
}

View File

@@ -17,10 +17,10 @@ import { useCrudTable } from './use-crud-table';
*/
const props = defineProps<{
crudApi: CrudApi;
/** 名称列标题,如「分类名称」 */
nameLabel: string;
/** 页面与弹窗标题,如「工厂分类」 */
moduleTitle: string;
/** 名称列标题,如「分类名称」 */
nameLabel: string;
}>();
const gridOptions = createGridOptions({
@@ -56,7 +56,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
:name-label="nameLabel"
/>
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{

View File

@@ -1,72 +1,89 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Modal, Upload } from 'ant-design-vue';
import type { UploadFile } from 'ant-design-vue';
// 恢复使用原有的上传接口
/**
* 表单图片上传。Vben 默认绑 value抽屉里常用 v-model两边都要回写
* 否则套餐封面这种选填图会「框里有图、提交是空」。
*/
import { computed, ref, watch } from 'vue';
import { Modal, Upload } from 'ant-design-vue';
import { uploadFile } from '#/api/core/upload';
import { Icon } from '#/components/icon';
// 定义接口
interface FileItem {
uid: string;
name: string;
status: 'uploading' | 'done' | 'error';
status: 'done' | 'error' | 'uploading';
url: string;
}
// 定义props
interface Props {
modelValue: string[];
multiple?: boolean;
maxCount?: number;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
multiple: true,
maxCount: 9,
});
const props = withDefaults(
defineProps<{
maxCount?: number;
modelValue?: string[];
multiple?: boolean;
value?: string[];
}>(),
{
maxCount: 9,
modelValue: undefined,
multiple: true,
value: undefined,
},
);
const emit = defineEmits<{
'update:modelValue': [value: string[]];
'update:value': [value: string[]];
}>();
const fileList = ref<FileItem[]>([]);
// 初始化fileList
const initFileList = () => {
fileList.value = props.modelValue.map((url) => ({
/** 表单 value 与 v-model 任一有值就用,避免只听一边丢图 */
function asUrls(input?: string[]) {
return Array.isArray(input) ? input.filter(Boolean) : [];
}
function currentUrls() {
const fromModel = asUrls(props.modelValue);
return fromModel.length > 0 ? fromModel : asUrls(props.value);
}
function toFileItems(urls: string[]) {
return urls.map((url) => ({
uid: url,
name: url.split('/').pop() || 'file',
status: 'done' as const,
url,
}));
};
}
// 初始化
initFileList();
fileList.value = toFileItems(currentUrls());
// 监听外部modelValue变化同步到fileList
watch(() => props.modelValue, (newVal) => {
const currentUrls = fileList.value.filter(file => file.status === 'done').map(file => file.url);
if (JSON.stringify(newVal) !== JSON.stringify(currentUrls)) {
fileList.value = newVal.map((url) => ({
uid: url,
name: url.split('/').pop() || 'file',
status: 'done' as const,
url,
}));
}
}, { deep: true });
watch(
() => [props.modelValue, props.value] as const,
() => {
const next = currentUrls();
const shown = fileList.value
.filter((file) => file.status === 'done')
.map((file) => file.url);
if (JSON.stringify(next) !== JSON.stringify(shown)) {
fileList.value = toFileItems(next);
}
},
{ deep: true },
);
// 统一通过updateModelValue函数更新modelValue
const updateModelValue = () => {
/** 同时回写 modelValue / valueVben 表单和原生 v-model 都能接到 */
function syncUrls() {
const urls = fileList.value
.filter((file) => file.status === 'done' && file.url)
.map((file) => file.url);
emit('update:modelValue', urls);
};
emit('update:value', urls);
}
// 使用原有的uploadFile接口
const customRequest = async (options: any) => {
@@ -85,26 +102,26 @@ const customRequest = async (options: any) => {
try {
// 使用原有的uploadFile接口
const res = await uploadFile({
file: file,
file,
});
const url =
(typeof res === 'string' ? res : '') ||
res?.url ||
res?.result?.url ||
'';
// 更新文件状态为完成
const updatedFileList = fileList.value.map(item =>
item.uid === file.uid
? { ...item, status: 'done' as const, url: res.url }
: item
const updatedFileList = fileList.value.map((item) =>
item.uid === file.uid ? { ...item, status: 'done' as const, url } : item,
);
fileList.value = updatedFileList;
updateModelValue();
syncUrls();
onSuccess(res);
} catch (error) {
console.error('上传失败', error);
// 更新文件状态为错误
const updatedFileList = fileList.value.map(item =>
item.uid === file.uid
? { ...item, status: 'error' as const }
: item
const updatedFileList = fileList.value.map((item) =>
item.uid === file.uid ? { ...item, status: 'error' as const } : item,
);
fileList.value = updatedFileList;
@@ -112,10 +129,9 @@ const customRequest = async (options: any) => {
}
};
// 处理文件删除
const handleRemove = (file: UploadFile) => {
fileList.value = fileList.value.filter((item) => item.uid !== file.uid);
updateModelValue();
syncUrls();
};
// 预览功能

View File

@@ -0,0 +1,369 @@
import type { Borders, Cell, Workbook, Worksheet } from 'exceljs';
import ExcelJS from 'exceljs';
/**
* 后台 Excel 导入导出ExcelJS
*
* 后端只给结构化数据xlsx 在浏览器里画:标题、表头、斑马纹、边框、冻结窗格。
* 导入也在前端拆成行再 POST rows避免 PHP 再引一套表格库。
*/
export interface ExcelColumn {
align?: 'center' | 'left' | 'right';
header: string;
key: string;
width?: number;
}
export interface ExcelSheetPayload {
columns: ExcelColumn[];
rows: Record<string, any>[];
sheet?: string;
/** 底部合计行key 与 columns 对齐 */
subtitle?: string;
summary?: Record<string, any>;
title?: string;
}
export interface ExcelExportPayload extends ExcelSheetPayload {
filename?: string;
/** 多工作表:商品导出时带报价单 */
sheets?: ExcelSheetPayload[];
}
/** 家具后台暖金色,跟小程序主题接近 */
const THEME = {
border: 'E5D9C8',
headerBg: 'B08D57',
headerFg: 'FFFFFF',
stripe: 'F7F1E8',
summaryBg: 'F3E6D0',
text: '1C1917',
titleBg: '1C1917',
titleFg: 'F8F4EE',
};
const THIN_BORDER: Partial<Borders> = {
bottom: { color: { argb: `FF${THEME.border}` }, style: 'thin' },
left: { color: { argb: `FF${THEME.border}` }, style: 'thin' },
right: { color: { argb: `FF${THEME.border}` }, style: 'thin' },
top: { color: { argb: `FF${THEME.border}` }, style: 'thin' },
};
/**
* 把接口返回的表格打成带样式的 xlsx 并下载(支持多工作表)
*/
export async function downloadStyledExcel(payload: ExcelExportPayload) {
const sheets = normalizeSheets(payload);
if (sheets.length === 0) {
throw new Error('没有可导出的列');
}
const workbook = new ExcelJS.Workbook();
workbook.creator = 'LGP Admin';
workbook.created = new Date();
sheets.forEach((item) => paintSheet(workbook, item));
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([new Uint8Array(buffer as ArrayBuffer)], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const name = toXlsxName(payload.filename || sheets[0]?.title || '导出');
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = name;
link.click();
URL.revokeObjectURL(url);
}
/**
* 读整个工作簿:每个工作表拆成行,跳过标题/副标题
*/
export async function parseExcelWorkbook(
file: File,
): Promise<{ name: string; rows: Record<string, any>[] }[]> {
const workbook = new ExcelJS.Workbook();
const buffer = await file.arrayBuffer();
await workbook.xlsx.load(buffer);
if (workbook.worksheets.length === 0) {
throw new Error('表格是空的');
}
return workbook.worksheets.map((sheet) => ({
name: sheet.name,
rows: rowsFromSheet(sheet),
}));
}
/**
* 读 xlsx默认取第一张表工厂等单表导入
*/
export async function parseExcelFile(
file: File,
): Promise<Record<string, any>[]> {
const book = await parseExcelWorkbook(file);
return book[0]?.rows ?? [];
}
/** 按工作表名取行,名称对不上就返回空 */
export function pickExcelSheet(
book: { name: string; rows: Record<string, any>[] }[],
names: string[],
): Record<string, any>[] {
const hit = book.find((item) => names.includes(item.name));
return hit?.rows ?? [];
}
/** 合并单元格时把整行底色铺满,避免只第一格有色 */
function normalizeSheets(payload: ExcelExportPayload): ExcelSheetPayload[] {
if (payload.sheets && payload.sheets.length > 0) {
return payload.sheets.filter((item) => (item.columns ?? []).length > 0);
}
if ((payload.columns ?? []).length === 0) {
return [];
}
return [
{
columns: payload.columns,
rows: payload.rows ?? [],
sheet: payload.sheet,
subtitle: payload.subtitle,
summary: payload.summary,
title: payload.title,
},
];
}
/** 画一张带标题、表头、斑马纹的工作表 */
function paintSheet(workbook: Workbook, payload: ExcelSheetPayload) {
const columns = payload.columns ?? [];
const rows = payload.rows ?? [];
const sheet = workbook.addWorksheet(payload.sheet || 'Sheet1', {
views: [{ showGridLines: false, state: 'frozen', ySplit: 3 }],
});
sheet.columns = columns.map((col) => ({
key: col.key,
width: col.width ?? Math.max(12, Math.min(36, col.header.length * 2 + 6)),
}));
const lastCol = columns.length;
const title = payload.title || payload.sheet || '导出';
sheet.mergeCells(1, 1, 1, lastCol);
const titleCell = sheet.getCell(1, 1);
titleCell.value = title;
titleCell.font = {
bold: true,
color: { argb: `FF${THEME.titleFg}` },
name: 'Microsoft YaHei',
size: 16,
};
titleCell.alignment = { horizontal: 'center', vertical: 'middle' };
titleCell.fill = {
fgColor: { argb: `FF${THEME.titleBg}` },
pattern: 'solid',
type: 'pattern',
};
fillRow(sheet, 1, lastCol, `FF${THEME.titleBg}`);
sheet.getRow(1).height = 32;
const subtitle =
payload.subtitle || `导出时间 ${formatNow()}${rows.length}`;
sheet.mergeCells(2, 1, 2, lastCol);
const subCell = sheet.getCell(2, 1);
subCell.value = subtitle;
subCell.font = {
color: { argb: 'FF78716C' },
name: 'Microsoft YaHei',
size: 10,
};
subCell.alignment = { horizontal: 'left', vertical: 'middle' };
subCell.fill = {
fgColor: { argb: 'FFF8F4EE' },
pattern: 'solid',
type: 'pattern',
};
fillRow(sheet, 2, lastCol, 'FFF8F4EE');
sheet.getRow(2).height = 20;
const headerRow = sheet.getRow(3);
headerRow.height = 24;
columns.forEach((col, index) => {
const cell = headerRow.getCell(index + 1);
cell.value = col.header;
cell.font = {
bold: true,
color: { argb: `FF${THEME.headerFg}` },
name: 'Microsoft YaHei',
size: 11,
};
cell.alignment = { horizontal: 'center', vertical: 'middle' };
cell.fill = {
fgColor: { argb: `FF${THEME.headerBg}` },
pattern: 'solid',
type: 'pattern',
};
cell.border = THIN_BORDER;
});
rows.forEach((row, rowIndex) => {
const excelRow = sheet.getRow(4 + rowIndex);
excelRow.height = 20;
columns.forEach((col, index) => {
const cell = excelRow.getCell(index + 1);
cell.value = normalizeCell(row[col.key]);
cell.font = {
color: { argb: `FF${THEME.text}` },
name: 'Microsoft YaHei',
size: 10,
};
cell.alignment = {
horizontal: col.align ?? 'left',
vertical: 'middle',
wrapText: true,
};
cell.border = THIN_BORDER;
if (rowIndex % 2 === 1) {
cell.fill = {
fgColor: { argb: `FF${THEME.stripe}` },
pattern: 'solid',
type: 'pattern',
};
}
});
});
if (payload.summary) {
const summaryRow = sheet.getRow(4 + rows.length);
summaryRow.height = 22;
columns.forEach((col, index) => {
const cell = summaryRow.getCell(index + 1);
cell.value = normalizeCell(payload.summary?.[col.key]);
cell.font = {
bold: true,
color: { argb: `FF${THEME.text}` },
name: 'Microsoft YaHei',
size: 11,
};
cell.alignment = { horizontal: col.align ?? 'left', vertical: 'middle' };
cell.fill = {
fgColor: { argb: `FF${THEME.summaryBg}` },
pattern: 'solid',
type: 'pattern',
};
cell.border = THIN_BORDER;
});
}
if (rows.length > 0) {
sheet.autoFilter = {
from: { column: 1, row: 3 },
to: { column: lastCol, row: 3 + rows.length },
};
}
sheet.pageSetup = {
fitToPage: true,
fitToWidth: 1,
orientation: 'landscape',
paperSize: 9,
};
}
function rowsFromSheet(sheet: Worksheet): Record<string, any>[] {
const matrix: string[][] = [];
sheet.eachRow({ includeEmpty: false }, (row) => {
const line: string[] = [];
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
line[colNumber - 1] = cellText(cell);
});
matrix.push(line);
});
const headerIndex = findHeaderRow(matrix);
const headers = (matrix[headerIndex] ?? []).map((item) =>
String(item).trim(),
);
if (headers.filter(Boolean).length === 0) {
return [];
}
const rows: Record<string, any>[] = [];
for (let i = headerIndex + 1; i < matrix.length; i += 1) {
const cols = matrix[i] ?? [];
if (cols.every((item) => String(item ?? '').trim() === '')) {
continue;
}
const item: Record<string, any> = {};
headers.forEach((key, index) => {
if (!key) return;
item[key] = String(cols[index] ?? '').trim();
});
const first = String(item[headers[0] ?? ''] ?? '');
if (first === '合计') {
continue;
}
rows.push(item);
}
return rows;
}
function fillRow(
sheet: Worksheet,
rowNumber: number,
lastCol: number,
argb: string,
) {
for (let col = 1; col <= lastCol; col += 1) {
sheet.getCell(rowNumber, col).fill = {
fgColor: { argb },
pattern: 'solid',
type: 'pattern',
};
}
}
function findHeaderRow(matrix: string[][]): number {
const limit = Math.min(8, matrix.length);
for (let i = 0; i < limit; i += 1) {
const filled = (matrix[i] ?? []).filter(
(cell) => String(cell ?? '').trim() !== '',
).length;
if (filled >= 3) {
return i;
}
}
return 0;
}
function cellText(cell: Cell): string {
const value = cell.value;
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'object' && value && 'text' in value) {
return String((value as { text: string }).text ?? '');
}
if (typeof value === 'object' && value && 'result' in value) {
return String((value as { result: unknown }).result ?? '');
}
if (value instanceof Date) {
return formatNow(value);
}
return String(value);
}
function normalizeCell(value: unknown): number | string {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'number') {
return value;
}
return String(value);
}
function toXlsxName(name: string): string {
return `${name.replace(/\.csv$/i, '.xlsx').replace(/\.xlsx$/i, '')}.xlsx`;
}
function formatNow(date = new Date()): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}

View File

@@ -403,3 +403,8 @@ export function downloadByData(
tempLink.remove();
window.URL.revokeObjectURL(blobURL);
}
/** 下载后端返回的 UTF-8 CSV 文本 */
export function downloadCsv(filename: string, csv: string) {
downloadByData(csv, filename || 'export.csv', 'text/csv;charset=utf-8');
}

View File

@@ -9,7 +9,12 @@ import { colorcardApi } from '../api';
export const gridOptions = createGridOptions({
columns: [
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{ field: 'cover', title: '色卡图', width: 90, slots: { default: 'cover' } },
{
field: 'cover',
title: '色卡图',
width: 120,
slots: { default: 'cover' },
},
{ field: 'description', align: 'left', title: '色卡说明', minWidth: 200 },
{ field: 'card_class_name', title: '所属分类', width: 140 },
{ field: 'company_name', title: '所属公司', width: 160 },

View File

@@ -34,7 +34,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
<Page auto-content-height title="色卡管理">
<FormModal />
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{
@@ -57,7 +57,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
/>
</template>
<template #cover="{ row }">
<Image v-if="row.cover" :height="40" :src="row.cover" :width="40" />
<Image v-if="row.cover" :height="72" :src="row.cover" :width="72" />
<span v-else>-</span>
</template>
<template #status="{ row }">

View File

@@ -32,7 +32,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
<Page auto-content-height title="公司管理">
<FormModal />
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{

View File

@@ -0,0 +1,18 @@
import { requestClient } from '#/api/request';
import { createCrudApi } from '#/components/crud';
/** 售后申请cc_after_sale通过只改状态线下退款 */
export const afterSaleApi = createCrudApi('after-sale');
/** 审核售后pass=true 通过 */
export async function auditAfterSale(data: {
admin_remark?: string;
id: number;
pass: boolean;
}) {
return requestClient.post<any>('after-sale/audit', {
id: data.id,
status: data.pass ? 1 : 0,
admin_remark: data.admin_remark ?? '',
});
}

View File

@@ -0,0 +1,52 @@
<script lang="ts" setup>
/**
* 售后审核弹窗。通过只改状态,不自动打微信退款。
*/
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { popupData } from '#/components/crud';
import { auditAfterSale } from '../api';
import { modalFormProps } from '../config/form';
defineOptions({
name: 'AfterSaleAuditModal',
});
const [Form, formApi] = useVbenForm(modalFormProps);
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const result = await formApi.validate();
if (!result.valid) return;
const values = await formApi.getValues();
modalApi.lock();
try {
await auditAfterSale({
id: Number(values.id),
pass: Number(values.pass) === 1,
admin_remark: String(values.admin_remark ?? ''),
});
message.success('已处理');
popupData(modalApi).gridApi?.reload();
modalApi.close();
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen) {
if (!isOpen) return;
const data = popupData(modalApi);
await formApi.setValues({ pass: 1, ...data.values });
},
});
</script>
<template>
<Modal class="w-[40%]" title="审核售后">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,12 @@
/** 售后状态0=待审 1=通过 2=拒绝 */
export const AFTER_SALE_STATUS_OPTIONS = [
{ label: '待审核', value: 0 },
{ label: '已通过', value: 1 },
{ label: '已拒绝', value: 2 },
];
export const AFTER_SALE_STATUS_TEXT: Record<number, string> = {
0: '待审核',
1: '已通过',
2: '已拒绝',
};

View File

@@ -0,0 +1,39 @@
import type { VbenFormProps } from '#/adapter/form';
/** 审核弹窗:通过/拒绝 + 备注 */
export const modalFormProps: VbenFormProps = {
commonConfig: {
componentProps: { class: 'w-full' },
formItemClass: 'col-span-12',
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
dependencies: { show: false, triggerFields: ['id'] },
fieldName: 'id',
label: 'ID',
},
{
component: 'Select',
componentProps: {
options: [
{ label: '通过(线下退款)', value: 1 },
{ label: '拒绝', value: 0 },
],
},
defaultValue: 1,
fieldName: 'pass',
label: '审核结果',
rules: 'selectRequired',
},
{
component: 'VbenTextarea',
componentProps: { rows: 3, placeholder: '审核备注,选填' },
fieldName: 'admin_remark',
label: '备注',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-12',
};

View File

@@ -0,0 +1,29 @@
import type { VbenFormProps } from '#/adapter/form';
import { AFTER_SALE_STATUS_OPTIONS } from './constants';
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'VbenInput',
componentProps: { placeholder: '订单 ID' },
fieldName: 'order_id',
label: '订单',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: AFTER_SALE_STATUS_OPTIONS,
placeholder: '请选择状态',
},
fieldName: 'status',
label: '状态',
},
],
showCollapseButton: true,
submitButtonOptions: { content: '查询' },
submitOnChange: true,
submitOnEnter: false,
};

View File

@@ -0,0 +1,29 @@
import { actionColumn, createGridOptions } from '#/components/crud';
import { afterSaleApi } from '../api';
export const gridOptions = createGridOptions({
columns: [
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{ field: 'order_no', title: '订单号', width: 180 },
{ field: 'user_name', title: '用户', width: 140 },
{ field: 'reason', align: 'left', title: '原因', minWidth: 200 },
{ field: 'amount_text', title: '申请金额', width: 110 },
{
field: 'images',
title: '图片',
width: 200,
slots: { default: 'images' },
},
{
field: 'status',
title: '状态',
width: 100,
slots: { default: 'status' },
},
{ field: 'admin_remark', align: 'left', title: '审核备注', minWidth: 160 },
{ field: 'created_at', title: '申请时间', width: 180 },
actionColumn(180),
],
query: (params) => afterSaleApi.list(params),
});

View File

@@ -0,0 +1,112 @@
<script lang="ts" setup>
/**
* 售后管理:线下退款,后台只审状态。
*/
import { Page } from '@vben/common-ui';
import { Image, Tag } from 'ant-design-vue';
import { useCrudTable } from '#/components/crud';
import { TableAction } from '#/components/table-action';
import { afterSaleApi } from './api';
import AfterSaleAuditModal from './components/modal.vue';
import { AFTER_SALE_STATUS_TEXT } from './config/constants';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
defineOptions({
name: 'CustomerAfterSale',
});
const { FormModal, Grid, hasSelection, removeRows, showModal } = useCrudTable({
api: afterSaleApi,
formOptions,
gridOptions,
modalComponent: AfterSaleAuditModal,
toFormValues: (row) => ({
admin_remark: row.admin_remark ?? '',
id: row.id,
pass: 1,
}),
});
function imageList(value?: string) {
return String(value ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function statusColor(status: number) {
if (status === 1) return 'green';
if (status === 2) return 'red';
return 'orange';
}
</script>
<template>
<Page auto-content-height title="售后管理">
<FormModal />
<Grid>
<template #toolbar-actions>
<TableAction
:actions="[
{
label: '删除',
danger: true,
icon: 'ant-design:delete-outlined',
disabled: !hasSelection,
popConfirm: {
title: '确定删除勾选的售后单吗?',
confirm: () => removeRows(),
},
},
]"
/>
</template>
<template #images="{ row }">
<div class="flex gap-1">
<Image
v-for="(src, index) in imageList(row.images)"
:key="index"
:height="56"
:src="src"
:width="56"
/>
<span v-if="imageList(row.images).length === 0">-</span>
</div>
</template>
<template #status="{ row }">
<Tag :color="statusColor(row.status)">
{{ AFTER_SALE_STATUS_TEXT[row.status] ?? '-' }}
</Tag>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '审核',
type: 'link',
icon: 'lucide:check',
size: 'small',
disabled: row.status !== 0,
onClick: () => showModal(row, true),
},
{
label: '删除',
type: 'link',
danger: true,
icon: 'ant-design:delete-outlined',
size: 'small',
popConfirm: {
title: '确定删除该售后单吗?',
confirm: () => removeRows(row.id),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
/**
* 经销商管理:仅 is_p=1 的微信用户
* 微信用户列表仍展示全部(含经销商),本页专注倍率、下级、企业与专属模板绑定
@@ -15,7 +17,6 @@ import {
Switch,
Tag,
} from 'ant-design-vue';
import { onMounted, ref } from 'vue';
import { useCrudTable } from '#/components/crud';
import { TableAction } from '#/components/table-action';
@@ -125,7 +126,7 @@ onMounted(loadTemplateOptions);
<BindModal />
<ChildrenDrawer />
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<span class="text-muted-foreground text-xs">
列表固定筛选 is_p=1专属模板仅经销商登录后优先生效
</span>
@@ -156,8 +157,12 @@ onMounted(loadTemplateOptions);
:min="0.01"
:precision="2"
:step="0.1"
:value="multiplierDraft[row.id] ?? Number(row.price_number ?? 1)"
@update:value="(val: any) => (multiplierDraft[row.id] = Number(val))"
:value="
multiplierDraft[row.id] ?? Number(row.price_number ?? 1)
"
@update:value="
(val: any) => (multiplierDraft[row.id] = Number(val))
"
/>
<Button size="small" type="primary" @click="saveMultiplier(row)">
保存

View File

@@ -9,7 +9,7 @@ import { enterpriseApi } from '../api';
export const gridOptions = createGridOptions({
columns: [
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{ field: 'logo', title: 'LOGO', width: 90, slots: { default: 'logo' } },
{ field: 'logo', title: 'LOGO', width: 120, slots: { default: 'logo' } },
{ field: 'name', align: 'left', title: '企业名称', minWidth: 200 },
{ field: 'contact_name', title: '联系人', width: 110 },
{ field: 'phone', title: '联系电话', width: 140 },

View File

@@ -49,7 +49,7 @@ function openDetail(row: Record<string, any>) {
<FormModal />
<DetailDrawer />
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{
@@ -72,7 +72,7 @@ function openDetail(row: Record<string, any>) {
/>
</template>
<template #logo="{ row }">
<Image v-if="row.logo" :height="40" :src="row.logo" :width="40" />
<Image v-if="row.logo" :height="72" :src="row.logo" :width="72" />
<span v-else>-</span>
</template>
<template #settle="{ row }">

View File

@@ -0,0 +1,10 @@
import { requestClient } from '#/api/request';
import { createCrudApi } from '#/components/crud';
/** 用户反馈cc_feedback */
export const feedbackApi = createCrudApi('feedback');
/** 回复反馈 */
export async function replyFeedback(data: { id: number; reply: string }) {
return requestClient.post<any>('feedback/reply', data);
}

View File

@@ -0,0 +1,51 @@
<script lang="ts" setup>
/**
* 反馈回复弹窗:后台不能代用户新建反馈,只能回复。
*/
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { popupData } from '#/components/crud';
import { replyFeedback } from '../api';
import { modalFormProps } from '../config/form';
defineOptions({
name: 'FeedbackReplyModal',
});
const [Form, formApi] = useVbenForm(modalFormProps);
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const result = await formApi.validate();
if (!result.valid) return;
const values = await formApi.getValues();
modalApi.lock();
try {
await replyFeedback({
id: Number(values.id),
reply: String(values.reply ?? ''),
});
message.success('已回复');
popupData(modalApi).gridApi?.reload();
modalApi.close();
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen) {
if (!isOpen) return;
const data = popupData(modalApi);
await formApi.setValues(data.values ?? {});
},
});
</script>
<template>
<Modal class="w-[40%]" title="回复反馈">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,10 @@
/** 反馈状态0=待处理 1=已回复 */
export const FEEDBACK_STATUS_OPTIONS = [
{ label: '待处理', value: 0 },
{ label: '已回复', value: 1 },
];
export const FEEDBACK_STATUS_TEXT: Record<number, string> = {
0: '待处理',
1: '已回复',
};

View File

@@ -0,0 +1,27 @@
import type { VbenFormProps } from '#/adapter/form';
/** 回复弹窗:只改 reply */
export const modalFormProps: VbenFormProps = {
commonConfig: {
componentProps: { class: 'w-full' },
formItemClass: 'col-span-12',
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
dependencies: { show: false, triggerFields: ['id'] },
fieldName: 'id',
label: 'ID',
},
{
component: 'VbenTextarea',
componentProps: { rows: 4, placeholder: '请填写回复内容' },
fieldName: 'reply',
label: '回复',
rules: 'required',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-12',
};

View File

@@ -0,0 +1,29 @@
import type { VbenFormProps } from '#/adapter/form';
import { FEEDBACK_STATUS_OPTIONS } from './constants';
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'VbenInput',
componentProps: { placeholder: '反馈内容' },
fieldName: 'content',
label: '内容',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: FEEDBACK_STATUS_OPTIONS,
placeholder: '请选择状态',
},
fieldName: 'status',
label: '状态',
},
],
showCollapseButton: true,
submitButtonOptions: { content: '查询' },
submitOnChange: true,
submitOnEnter: false,
};

View File

@@ -0,0 +1,28 @@
import { actionColumn, createGridOptions } from '#/components/crud';
import { feedbackApi } from '../api';
export const gridOptions = createGridOptions({
columns: [
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{ field: 'user_name', title: '用户', width: 140 },
{ field: 'user_phone', title: '手机', width: 130 },
{ field: 'content', align: 'left', title: '反馈内容', minWidth: 240 },
{
field: 'images',
title: '图片',
width: 200,
slots: { default: 'images' },
},
{ field: 'reply', align: 'left', title: '回复', minWidth: 180 },
{
field: 'status',
title: '状态',
width: 100,
slots: { default: 'status' },
},
{ field: 'created_at', title: '提交时间', width: 180 },
actionColumn(160),
],
query: (params) => feedbackApi.list(params),
});

View File

@@ -0,0 +1,104 @@
<script lang="ts" setup>
/**
* 用户反馈:列表 + 回复 + 删除。反馈由小程序提交,后台不新建。
*/
import { Page } from '@vben/common-ui';
import { Image, Tag } from 'ant-design-vue';
import { useCrudTable } from '#/components/crud';
import { TableAction } from '#/components/table-action';
import { feedbackApi } from './api';
import FeedbackReplyModal from './components/modal.vue';
import { FEEDBACK_STATUS_TEXT } from './config/constants';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
defineOptions({
name: 'CustomerFeedback',
});
const { FormModal, Grid, hasSelection, removeRows, showModal } = useCrudTable({
api: feedbackApi,
formOptions,
gridOptions,
modalComponent: FeedbackReplyModal,
toFormValues: (row) => ({
id: row.id,
reply: row.reply ?? '',
}),
});
function imageList(value?: string) {
return String(value ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
</script>
<template>
<Page auto-content-height title="用户反馈">
<FormModal />
<Grid>
<template #toolbar-actions>
<TableAction
:actions="[
{
label: '删除',
danger: true,
icon: 'ant-design:delete-outlined',
disabled: !hasSelection,
popConfirm: {
title: '确定删除勾选的反馈吗?',
confirm: () => removeRows(),
},
},
]"
/>
</template>
<template #images="{ row }">
<div class="flex gap-1">
<Image
v-for="(src, index) in imageList(row.images)"
:key="index"
:height="56"
:src="src"
:width="56"
/>
<span v-if="imageList(row.images).length === 0">-</span>
</div>
</template>
<template #status="{ row }">
<Tag :color="row.status === 1 ? 'green' : 'orange'">
{{ FEEDBACK_STATUS_TEXT[row.status] ?? '-' }}
</Tag>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '回复',
type: 'link',
icon: 'lucide:reply',
size: 'small',
onClick: () => showModal(row, true),
},
{
label: '删除',
type: 'link',
danger: true,
icon: 'ant-design:delete-outlined',
size: 'small',
popConfirm: {
title: '确定删除该反馈吗?',
confirm: () => removeRows(row.id),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -26,11 +26,11 @@ export async function getListByUser(userId: number): Promise<any[]> {
* params 里收货信息是可选的,缺省时后端用用户的昵称/手机兜底。
*/
export async function listToOrder(data: {
id: number;
delivery_type?: number;
id: number;
receiver_address?: string;
receiver_name?: string;
receiver_phone?: string;
receiver_address?: string;
remark?: string;
}) {
return requestClient.post<any>('list/to-order', data);
@@ -48,3 +48,8 @@ export async function saveListItem(data: Record<string, any>) {
export async function deleteListItem(ids: number[]) {
return requestClient.post<any>('list/delete-item', { ids });
}
/** 导出清单报价:后端给结构化数据,前端 ExcelJS 画 xlsx */
export async function exportListQuote(id: number) {
return requestClient.get<any>('list/export-quote', { params: { id } });
}

View File

@@ -3,10 +3,28 @@ import { ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { Alert, Button, Descriptions, DescriptionsItem, Empty, Image, Input, message, Select, Spin, Table, Tag } from 'ant-design-vue';
import {
Alert,
Button,
Descriptions,
DescriptionsItem,
Empty,
Image,
Input,
message,
Select,
Spin,
Table,
Tag,
} from 'ant-design-vue';
import { popupData } from '#/components/crud';
import { listApi, listToOrder } from '#/views/customer/list/api';
import { downloadStyledExcel } from '#/util/excel';
import {
exportListQuote,
listApi,
listToOrder,
} from '#/views/customer/list/api';
import { DELIVERY_TYPE_OPTIONS, LIST_STATUS_TEXT } from '../config/constants';
@@ -75,6 +93,14 @@ function resetOrderForm() {
remark.value = '';
}
async function exportQuote() {
const id = Number(record.value.id ?? 0);
if (id <= 0) return;
const res = await exportListQuote(id);
await downloadStyledExcel(res);
message.success('已导出报价');
}
async function submitToOrder() {
const id = Number(record.value.id ?? 0);
if (id <= 0) return;
@@ -130,9 +156,27 @@ async function submitToOrder() {
</DescriptionsItem>
<DescriptionsItem label="合计">
<span class="text-primary font-semibold">
¥{{ detail.total_amount_text ?? '0' }}
¥{{
detail.payable_amount_text || detail.total_amount_text || '0'
}}
</span>
</DescriptionsItem>
<template v-if="Number(detail.package_id) > 0">
<DescriptionsItem label="套餐原价">
¥{{ detail.original_amount_text ?? '0' }}
</DescriptionsItem>
<DescriptionsItem label="套餐价">
¥{{ detail.package_amount_text ?? '0' }}
</DescriptionsItem>
<DescriptionsItem label="变更差价">
¥{{ detail.diff_amount_text ?? '0' }}
</DescriptionsItem>
<DescriptionsItem label="应付">
<span class="text-primary font-semibold">
¥{{ detail.payable_amount_text ?? '0' }}
</span>
</DescriptionsItem>
</template>
</Descriptions>
<Table
@@ -155,6 +199,10 @@ async function submitToOrder() {
</template>
</Table>
<div class="flex gap-2">
<Button @click="exportQuote">导出报价</Button>
</div>
<Alert
v-if="detail.status === 1"
message="该清单已转订单,无需再次下单。"
@@ -178,10 +226,7 @@ async function submitToOrder() {
<Input v-model:value="receiverName" placeholder="收件人" />
<Input v-model:value="receiverPhone" placeholder="联系电话" />
</div>
<Input
v-model:value="receiverAddress"
placeholder="收货地址"
/>
<Input v-model:value="receiverAddress" placeholder="收货地址" />
<Select
v-model:value="deliveryType"
:options="DELIVERY_TYPE_OPTIONS"
@@ -189,12 +234,8 @@ async function submitToOrder() {
/>
<Input v-model:value="remark" placeholder="订单备注" />
<div class="flex gap-2">
<Button type="primary" @click="submitToOrder">
确认下单
</Button>
<Button @click="showOrderForm = false">
取消
</Button>
<Button type="primary" @click="submitToOrder"> 确认下单 </Button>
<Button @click="showOrderForm = false"> 取消 </Button>
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { message, Tag } from 'ant-design-vue';
import { Tag } from 'ant-design-vue';
import { useCrudTable } from '#/components/crud';
import UserDetailModal from '#/components/customer/user-detail-modal.vue';
@@ -62,7 +62,7 @@ async function batchDelete() {
<DetailDrawer />
<UserModal />
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{

View File

@@ -3,11 +3,12 @@ import { onMounted, ref } from 'vue';
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { Icon } from '#/components/icon';
import { Spin, Tag } from 'ant-design-vue';
import { useCrudTable } from '#/components/crud';
import UserDetailModal from '#/components/customer/user-detail-modal.vue';
import { Icon } from '#/components/icon';
import { TableAction } from '#/components/table-action';
import { Spin, Tag } from 'ant-design-vue';
import { getOrderStat, orderApi } from './api';
import CustomerOrderDetailDrawer from './components/detail-drawer.vue';
@@ -50,11 +51,11 @@ const [UserModal, userModalApi] = useVbenModal({
/** 顶部统计:后端 order/stat 返回各状态数量与总金额 */
const statLoading = ref(false);
const stat = ref<{
total: number;
amount: number;
amount_text: string;
auditing: number;
status: { status: number; count: number; amount: number }[];
status: { amount: number; count: number; status: number }[];
total: number;
}>({ total: 0, amount: 0, amount_text: '0', auditing: 0, status: [] });
async function loadStat() {
@@ -67,10 +68,31 @@ async function loadStat() {
}
const statCards = [
{ key: 'total', label: '订单总数', icon: 'lucide:receipt', getValue: (s: typeof stat.value) => String(s.total) },
{ key: 'amount', label: '订单总额', icon: 'lucide:circle-dollar-sign', getValue: (s: typeof stat.value) => `¥${s.amount_text}` },
{ key: 'auditing', label: '待审凭证', icon: 'lucide:badge-check', getValue: (s: typeof stat.value) => String(s.auditing) },
{ key: 'pending', label: '待付款', icon: 'lucide:clock', getValue: (s: typeof stat.value) => String(s.status.find((i) => i.status === 0)?.count ?? 0) },
{
key: 'total',
label: '订单总数',
icon: 'lucide:receipt',
getValue: (s: typeof stat.value) => String(s.total),
},
{
key: 'amount',
label: '订单总额',
icon: 'lucide:circle-dollar-sign',
getValue: (s: typeof stat.value) => `¥${s.amount_text}`,
},
{
key: 'auditing',
label: '待审凭证',
icon: 'lucide:badge-check',
getValue: (s: typeof stat.value) => String(s.auditing),
},
{
key: 'pending',
label: '待付款',
icon: 'lucide:clock',
getValue: (s: typeof stat.value) =>
String(s.status.find((i) => i.status === 0)?.count ?? 0),
},
];
onMounted(loadStat);
@@ -87,7 +109,11 @@ function openUser(row: Record<string, any>) {
</script>
<template>
<Page auto-content-height content-class="flex flex-col gap-3" title="订单管理">
<Page
auto-content-height
content-class="flex flex-col gap-3"
title="订单管理"
>
<FormModal />
<DetailDrawer />
<UserModal />
@@ -116,7 +142,7 @@ function openUser(row: Record<string, any>) {
</Spin>
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{

View File

@@ -1,8 +1,17 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { Avatar, Button, InputNumber, message, Popover, Switch, Tag } from 'ant-design-vue';
import { ref } from 'vue';
import {
Avatar,
Button,
InputNumber,
message,
Popover,
Switch,
Tag,
} from 'ant-design-vue';
import { useCrudTable } from '#/components/crud';
import { TableAction } from '#/components/table-action';
@@ -79,7 +88,7 @@ async function saveMultiplier(row: Record<string, any>) {
<BindModal />
<ChildrenDrawer />
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<span class="text-xs text-muted-foreground">
微信用户由小程序授权登录产生后台不支持新建
</span>
@@ -118,8 +127,12 @@ async function saveMultiplier(row: Record<string, any>) {
:min="0.01"
:precision="2"
:step="0.1"
:value="multiplierDraft[row.id] ?? Number(row.price_number ?? 1)"
@update:value="(val: any) => (multiplierDraft[row.id] = Number(val))"
:value="
multiplierDraft[row.id] ?? Number(row.price_number ?? 1)
"
@update:value="
(val: any) => (multiplierDraft[row.id] = Number(val))
"
/>
<Button size="small" type="primary" @click="saveMultiplier(row)">
保存

View File

@@ -1,12 +1,12 @@
<script lang="ts" setup>
/**
* 分析页:用订单真实统计替换 Vben 假数据12 万用户等)。
*/
import type { AnalysisOverviewItem } from '@vben/common-ui';
import type { TabOption } from '@vben/types';
import {
AnalysisChartCard,
AnalysisChartsTabs,
AnalysisOverview,
} from '@vben/common-ui';
import { onMounted, ref } from 'vue';
import { AnalysisOverview } from '@vben/common-ui';
import {
SvgBellIcon,
SvgCakeIcon,
@@ -14,77 +14,76 @@ import {
SvgDownloadIcon,
} from '@vben/icons';
import AnalyticsTrends from './analytics-trends.vue';
import AnalyticsVisitsData from './analytics-visits-data.vue';
import AnalyticsVisitsSales from './analytics-visits-sales.vue';
import AnalyticsVisitsSource from './analytics-visits-source.vue';
import AnalyticsVisits from './analytics-visits.vue';
import { getOrderStat } from '#/views/customer/order/api';
const overviewItems: AnalysisOverviewItem[] = [
const overviewItems = ref<AnalysisOverviewItem[]>([
{
icon: SvgCardIcon,
title: '用户量',
totalTitle: '总用户量',
totalValue: 120_000,
value: 2000,
title: '订单数',
totalTitle: '全部订单',
totalValue: 0,
value: 0,
},
{
icon: SvgCakeIcon,
title: '访问量',
totalTitle: '总访问量',
totalValue: 500_000,
value: 20_000,
title: '成交额',
totalTitle: '累计成交(元)',
totalValue: 0,
value: 0,
},
{
icon: SvgDownloadIcon,
title: '下载量',
totalTitle: '总下载量',
totalValue: 120_000,
value: 8000,
title: '清单',
totalTitle: '全部清单',
totalValue: 0,
value: 0,
},
{
icon: SvgBellIcon,
title: '使用量',
totalTitle: '总使用量',
totalValue: 50_000,
value: 5000,
title: '微信用户',
totalTitle: '全部用户',
totalValue: 0,
value: 0,
},
];
]);
const chartTabs: TabOption[] = [
{
label: '流量趋势',
value: 'trends',
},
{
label: '月访问量',
value: 'visits',
},
];
onMounted(async () => {
const data = (await getOrderStat()) ?? {};
overviewItems.value = [
{
icon: SvgCardIcon,
title: '待付款',
totalTitle: '全部订单',
totalValue: data.total ?? 0,
value: data.unpaid ?? 0,
},
{
icon: SvgCakeIcon,
title: '待审凭证',
totalTitle: '累计成交(元)',
totalValue: data.amount_text ?? 0,
value: data.auditing ?? 0,
},
{
icon: SvgDownloadIcon,
title: '待发货',
totalTitle: '全部清单',
totalValue: data.list_count ?? 0,
value: data.paid ?? 0,
},
{
icon: SvgBellIcon,
title: '已发货',
totalTitle: '微信用户',
totalValue: data.wx_user_count ?? 0,
value: data.shipped ?? 0,
},
];
});
</script>
<template>
<div class="p-5">
<AnalysisOverview :items="overviewItems" />
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
<template #trends>
<AnalyticsTrends />
</template>
<template #visits>
<AnalyticsVisits />
</template>
</AnalysisChartsTabs>
<div class="mt-5 w-full md:flex">
<AnalysisChartCard class="mt-5 md:mt-0 md:mr-4 md:w-1/3" title="访问数量">
<AnalyticsVisitsData />
</AnalysisChartCard>
<AnalysisChartCard class="mt-5 md:mt-0 md:mr-4 md:w-1/3" title="访问来源">
<AnalyticsVisitsSource />
</AnalysisChartCard>
<AnalysisChartCard class="mt-5 md:mt-0 md:w-1/3" title="访问来源">
<AnalyticsVisitsSales />
</AnalysisChartCard>
</div>
</div>
</template>

View File

@@ -1,236 +1,151 @@
<script lang="ts" setup>
/**
* 工作台:用订单/清单/用户真实统计替换 Vben 示例数据。
*/
import type {
WorkbenchProjectItem,
WorkbenchQuickNavItem,
WorkbenchTodoItem,
WorkbenchTrendItem,
} from '@vben/common-ui';
import { ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import {
AnalysisChartCard,
WorkbenchHeader,
WorkbenchProject,
WorkbenchQuickNav,
WorkbenchTodo,
WorkbenchTrends,
} from '@vben/common-ui';
import { preferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
import AnalyticsVisitsSource from '../analytics/analytics-visits-source.vue';
import { getOrderStat } from '#/views/customer/order/api';
const userStore = useUserStore();
const router = useRouter();
// 这是一个示例数据,实际项目中需要根据实际情况进行调整
// url 也可以是内部路由,在 navTo 方法中识别处理,进行内部跳转
// 例如url: /dashboard/workspace
const projectItems: WorkbenchProjectItem[] = [
{
color: '',
content: '不要等待机会,而要创造机会。',
date: '2021-04-01',
group: '开源组',
icon: 'carbon:logo-github',
title: 'Github',
url: 'https://github.com',
},
{
color: '#3fb27f',
content: '现在的你决定将来的你。',
date: '2021-04-01',
group: '算法组',
icon: 'ion:logo-vue',
title: 'Vue',
url: 'https://vuejs.org',
},
{
color: '#e18525',
content: '没有什么才能比努力更重要。',
date: '2021-04-01',
group: '上班摸鱼',
icon: 'ion:logo-html5',
title: 'Html5',
url: 'https://developer.mozilla.org/zh-CN/docs/Web/HTML',
},
{
color: '#bf0c2c',
content: '热情和欲望可以突破一切难关。',
date: '2021-04-01',
group: 'UI',
icon: 'ion:logo-angular',
title: 'Angular',
url: 'https://angular.io',
},
{
color: '#00d8ff',
content: '健康的身体是实现目标的基石。',
date: '2021-04-01',
group: '技术牛',
icon: 'bx:bxl-react',
title: 'React',
url: 'https://reactjs.org',
},
{
color: '#EBD94E',
content: '路是走出来的,而不是空想出来的。',
date: '2021-04-01',
group: '架构组',
icon: 'ion:logo-javascript',
title: 'Js',
url: 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript',
},
];
const stat = ref<Record<string, any>>({});
const projectItems = ref<WorkbenchProjectItem[]>([]);
const todoItems = ref<WorkbenchTodoItem[]>([]);
// 同样,这里的 url 也可以使用以 http 开头的外部链接
const quickNavItems: WorkbenchQuickNavItem[] = [
{
color: '#1fdaca',
icon: 'ion:home-outline',
title: '首页',
url: '/',
},
{
color: '#bf0c2c',
icon: 'ion:grid-outline',
title: '仪表盘',
url: '/dashboard',
icon: 'ion:cube-outline',
title: '商品图册',
url: '/goods/catalogue',
},
{
color: '#e18525',
icon: 'ion:layers-outline',
title: '组件',
url: '/demos/features/icons',
title: '套餐搭配',
url: '/goods/package',
},
{
color: '#3fb27f',
icon: 'ion:settings-outline',
title: '系统管理',
url: '/demos/features/login-expired', // 这里的 URL 是示例,实际项目中需要根据实际情况进行调整
icon: 'ion:list-outline',
title: '清单管理',
url: '/customer/list',
},
{
color: '#bf0c2c',
icon: 'ion:receipt-outline',
title: '订单管理',
url: '/customer/order',
},
{
color: '#4daf1bc9',
icon: 'ion:key-outline',
title: '权限管理',
url: '/demos/access/page-control',
icon: 'ion:people-outline',
title: '微信用户',
url: '/customer/wx-user',
},
{
color: '#00d8ff',
icon: 'ion:bar-chart-outline',
title: '图表',
url: '/analytics',
icon: 'ion:chatbubble-outline',
title: '用户反馈',
url: '/customer/feedback',
},
{
color: '#8b5cf6',
icon: 'ion:return-down-back-outline',
title: '售后管理',
url: '/customer/after-sale',
},
];
const todoItems = ref<WorkbenchTodoItem[]>([
{
completed: false,
content: `审查最近提交到Git仓库的前端代码确保代码质量和规范。`,
date: '2024-07-30 11:00:00',
title: '审查前端代码提交',
},
{
completed: true,
content: `检查并优化系统性能降低CPU使用率。`,
date: '2024-07-30 11:00:00',
title: '系统性能优化',
},
{
completed: false,
content: `进行系统安全检查,确保没有安全漏洞或未授权的访问。 `,
date: '2024-07-30 11:00:00',
title: '安全检查',
},
{
completed: false,
content: `更新项目中的所有npm依赖包确保使用最新版本。`,
date: '2024-07-30 11:00:00',
title: '更新项目依赖',
},
{
completed: false,
content: `修复用户报告的页面UI显示问题确保在不同浏览器中显示一致。 `,
date: '2024-07-30 11:00:00',
title: '修复UI显示问题',
},
]);
const trendItems: WorkbenchTrendItem[] = [
{
avatar: 'svg:avatar-1',
content: `在 <a>开源组</a> 创建了项目 <a>Vue</a>`,
date: '刚刚',
title: '威廉',
},
{
avatar: 'svg:avatar-2',
content: `关注了 <a>威廉</a> `,
date: '1个小时前',
title: '艾文',
},
{
avatar: 'svg:avatar-3',
content: `发布了 <a>个人动态</a> `,
date: '1天前',
title: '克里斯',
},
{
avatar: 'svg:avatar-4',
content: `发表文章 <a>如何编写一个Vite插件</a> `,
date: '2天前',
title: 'Vben',
},
{
avatar: 'svg:avatar-1',
content: `回复了 <a>杰克</a> 的问题 <a>如何进行项目优化?</a>`,
date: '3天前',
title: '皮特',
},
{
avatar: 'svg:avatar-2',
content: `关闭了问题 <a>如何运行项目</a> `,
date: '1周前',
title: '杰克',
},
{
avatar: 'svg:avatar-3',
content: `发布了 <a>个人动态</a> `,
date: '1周前',
title: '威廉',
},
{
avatar: 'svg:avatar-4',
content: `推送了代码到 <a>Github</a>`,
date: '2021-04-01 20:00',
title: '威廉',
},
{
avatar: 'svg:avatar-4',
content: `发表文章 <a>如何编写使用 Admin Vben</a> `,
date: '2021-03-01 20:00',
title: 'Vben',
},
];
const router = useRouter();
// 这是一个示例方法,实际项目中需要根据实际情况进行调整
// This is a sample method, adjust according to the actual project requirements
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
if (nav.url?.startsWith('http')) {
openWindow(nav.url);
return;
}
if (nav.url?.startsWith('/')) {
router.push(nav.url).catch((error) => {
console.error('Navigation failed:', error);
});
} else {
console.warn(`Unknown URL for navigation item: ${nav.title} -> ${nav.url}`);
router.push(nav.url).catch(() => undefined);
}
}
/** 把订单概览铺到工作台卡片和待办 */
async function loadStat() {
const data = (await getOrderStat()) ?? {};
stat.value = data;
projectItems.value = [
{
color: '#3fb27f',
content: `待付款 ${data.unpaid ?? 0}`,
date: '',
group: '订单',
icon: 'ion:card-outline',
title: `订单 ${data.total ?? 0}`,
url: '/customer/order',
},
{
color: '#e18525',
content: `待审凭证 ${data.auditing ?? 0}`,
date: '',
group: '收款',
icon: 'ion:cash-outline',
title: `成交 ¥${data.amount_text ?? '0'}`,
url: '/customer/order',
},
{
color: '#1fdaca',
content: '客户选品清单',
date: '',
group: '清单',
icon: 'ion:list-outline',
title: `清单 ${data.list_count ?? 0}`,
url: '/customer/list',
},
{
color: '#00d8ff',
content: '小程序授权用户',
date: '',
group: '客户',
icon: 'ion:people-outline',
title: `用户 ${data.wx_user_count ?? 0}`,
url: '/customer/wx-user',
},
];
todoItems.value = [
{
completed: (data.auditing ?? 0) === 0,
content: '转账凭证待审核,通过后订单会记为已付款。',
date: '',
title: `待审凭证 ${data.auditing ?? 0}`,
},
{
completed: (data.unpaid ?? 0) === 0,
content: '未付款订单,可跟进客户或后台代客确认。',
date: '',
title: `待付款 ${data.unpaid ?? 0}`,
},
{
completed: (data.paid ?? 0) === 0,
content: '已付款待发货,请及时填写物流或自提点。',
date: '',
title: `待发货 ${data.paid ?? 0}`,
},
];
}
onMounted(loadStat);
</script>
<template>
@@ -239,15 +154,21 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
:avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar"
>
<template #title>
早安, {{ userStore.userInfo?.realName }}, 开始您一天的工作吧
{{ userStore.userInfo?.realName || '管理员' }}开始处理业务吧
</template>
<template #description>
成交 ¥{{ stat.amount_text || '0' }} · 待审 {{ stat.auditing || 0 }} ·
用户 {{ stat.wx_user_count || 0 }}
</template>
<template #description> 今日晴20 - 32 </template>
</WorkbenchHeader>
<div class="flex flex-col lg:flex-row">
<div class="mr-4 w-full lg:w-3/5">
<WorkbenchProject :items="projectItems" title="项目" @click="navTo" />
<WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" />
<WorkbenchProject
:items="projectItems"
title="业务概览"
@click="navTo"
/>
</div>
<div class="w-full lg:w-2/5">
<WorkbenchQuickNav
@@ -256,10 +177,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
title="快捷导航"
@click="navTo"
/>
<WorkbenchTodo :items="todoItems" class="mt-5" title="待办事项" />
<AnalysisChartCard class="mt-5" title="访问来源">
<AnalyticsVisitsSource />
</AnalysisChartCard>
<WorkbenchTodo :items="todoItems" class="mt-5" title="待办" />
</div>
</div>
</div>

View File

@@ -9,7 +9,7 @@ import { factoryImageApi } from '../api';
export const gridOptions = createGridOptions({
columns: [
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{ field: 'url', title: '产品图', width: 100, slots: { default: 'image' } },
{ field: 'url', title: '产品图', width: 120, slots: { default: 'image' } },
{ field: 'factory_name', align: 'left', title: '所属工厂', minWidth: 200 },
statusColumn,
{ field: 'created_at', title: '创建时间', width: 180 },

View File

@@ -37,7 +37,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
/>
<FormModal />
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{
@@ -60,7 +60,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
/>
</template>
<template #image="{ row }">
<Image v-if="row.url" :height="40" :src="row.url" :width="40" />
<Image v-if="row.url" :height="72" :src="row.url" :width="72" />
<span v-else>-</span>
</template>
<template #status="{ row }">

View File

@@ -1,7 +1,16 @@
import { requestClient } from '#/api/request';
import { createCrudApi } from '#/components/crud';
export const factoryInfoApi = createCrudApi('factory-info');
export async function exportFactoryExcel() {
return requestClient.get<any>('factory-info/export');
}
export async function importFactoryExcel(rows: Record<string, any>[]) {
return requestClient.post<any>('factory-info/import', { rows });
}
/** 工厂下拉 */
export async function getFactoryOption() {
return (await factoryInfoApi.option()) ?? [];

View File

@@ -9,7 +9,7 @@ import { factoryInfoApi } from '../api';
export const gridOptions = createGridOptions({
columns: [
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{ field: 'cover', title: '封面', width: 90, slots: { default: 'cover' } },
{ field: 'cover', title: '封面', width: 120, slots: { default: 'cover' } },
{ field: 'name', align: 'left', title: '工厂名称', minWidth: 200 },
{ field: 'classification_name', title: '所属分类', width: 140 },
{ field: 'phone', title: '联系电话', width: 140 },

View File

@@ -1,34 +1,42 @@
<script lang="ts" setup>
import { Page, useVbenDrawer } from '@vben/common-ui';
import { Image, Switch } from 'ant-design-vue';
import { Button, Image, message, Switch, Upload } from 'ant-design-vue';
import { toImageList, useCrudTable } from '#/components/crud';
import { TableAction } from '#/components/table-action';
import { downloadStyledExcel, parseExcelFile } from '#/util/excel';
import { factoryInfoApi } from './api';
import { exportFactoryExcel, factoryInfoApi, importFactoryExcel } from './api';
import FactoryAlbumDrawer from './components/album-drawer.vue';
import FactoryInfoFormModal from './components/modal.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
const { FormModal, Grid, gridApi, hasSelection, removeRows, showModal, toggleStatus } =
useCrudTable({
api: factoryInfoApi,
defaultValues: { status: 0 },
formOptions,
gridOptions,
modalComponent: FactoryInfoFormModal,
toFormValues: (row) => ({
address: row.address,
classification: row.classification,
cover: toImageList(row.cover),
id: row.id,
name: row.name,
phone: row.phone,
status: row.status ?? 0,
}),
});
const {
FormModal,
Grid,
gridApi,
hasSelection,
removeRows,
showModal,
toggleStatus,
} = useCrudTable({
api: factoryInfoApi,
defaultValues: { status: 0 },
formOptions,
gridOptions,
modalComponent: FactoryInfoFormModal,
toFormValues: (row) => ({
address: row.address,
classification: row.classification,
cover: toImageList(row.cover),
id: row.id,
name: row.name,
phone: row.phone,
status: row.status ?? 0,
}),
});
const [AlbumDrawer, albumDrawerApi] = useVbenDrawer({
connectedComponent: FactoryAlbumDrawer,
@@ -38,6 +46,30 @@ function openAlbum(row: Record<string, any>) {
albumDrawerApi.setData({ gridApi, record: row });
albumDrawerApi.open();
}
async function exportFactory() {
const res = await exportFactoryExcel();
await downloadStyledExcel(res);
message.success('已导出');
}
async function importFactory(file: File) {
try {
const rows = await parseExcelFile(file);
if (rows.length === 0) {
message.warning('表格没有数据行');
return false;
}
const res = await importFactoryExcel(rows);
message.success(
`导入完成:新增 ${res?.created ?? 0},更新 ${res?.updated ?? 0}`,
);
gridApi.reload();
} catch (error: any) {
message.error(error?.message || '导入失败');
}
return false;
}
</script>
<template>
@@ -45,7 +77,7 @@ function openAlbum(row: Record<string, any>) {
<FormModal />
<AlbumDrawer />
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{
@@ -54,6 +86,11 @@ function openAlbum(row: Record<string, any>) {
icon: 'ant-design:plus-outlined',
onClick: () => showModal(),
},
{
label: '导出',
icon: 'ant-design:download-outlined',
onClick: () => exportFactory(),
},
{
label: '删除',
danger: true,
@@ -66,9 +103,16 @@ function openAlbum(row: Record<string, any>) {
},
]"
/>
<Upload
:before-upload="importFactory"
:show-upload-list="false"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
>
<Button>导入 Excel</Button>
</Upload>
</template>
<template #cover="{ row }">
<Image v-if="row.cover" :height="40" :src="row.cover" :width="40" />
<Image v-if="row.cover" :height="72" :src="row.cover" :width="72" />
<span v-else>-</span>
</template>
<template #status="{ row }">

View File

@@ -9,7 +9,7 @@ import { carouselApi } from '../api';
export const gridOptions = createGridOptions({
columns: [
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{ field: 'url', title: '轮播图', width: 140, slots: { default: 'image' } },
{ field: 'url', title: '轮播图', width: 160, slots: { default: 'image' } },
{ field: 'to_path', align: 'left', title: '跳转路径', minWidth: 260 },
{ field: 'sort', title: '排序', width: 90 },
statusColumn,

View File

@@ -32,7 +32,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
<Page auto-content-height title="轮播图">
<FormModal />
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{
@@ -55,7 +55,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
/>
</template>
<template #image="{ row }">
<Image v-if="row.url" :height="40" :src="row.url" :width="80" />
<Image v-if="row.url" :height="60" :src="row.url" :width="120" />
<span v-else>-</span>
</template>
<template #status="{ row }">

View File

@@ -3,7 +3,7 @@ import { ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { Alert, Button, Input, message } from 'ant-design-vue';
import { Alert, Button, Input, InputNumber, message } from 'ant-design-vue';
import { popupData } from '#/components/crud';
import { Icon } from '#/components/icon';
@@ -21,20 +21,20 @@ interface SpecRow {
id?: number;
routine: string;
specification: string;
/** -1 表示不限库存 */
stock: number;
}
const record = ref<Record<string, any>>({});
const rows = ref<SpecRow[]>([]);
function emptyRow(): SpecRow {
return { dimension: '', routine: '', specification: '' };
return { dimension: '', routine: '', specification: '', stock: -1 };
}
const [Drawer, drawerApi] = useVbenDrawer({
async onConfirm() {
const payload = rows.value.filter(
(row) => row.specification.trim() !== '',
);
const payload = rows.value.filter((row) => row.specification.trim() !== '');
if (payload.length === 0) {
message.warning('请至少填写一行规格');
return;
@@ -67,6 +67,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
id: item.id,
routine: item.routine ?? '',
specification: item.specification ?? '',
stock: Number(item.stock ?? -1),
}))
: [emptyRow()];
} finally {
@@ -90,7 +91,7 @@ defineExpose(drawerApi);
</script>
<template>
<Drawer class="w-[720px]" :title="`规格报价 - ${record.title ?? ''}`">
<Drawer class="w-[860px]" :title="`规格报价 - ${record.title ?? ''}`">
<Alert
class="mb-3"
message="保存是覆盖式的:这里删掉的行提交后会一并删除。价格支持「名称:价格」写法,小程序端只对价格段乘代理商倍率。"
@@ -98,9 +99,10 @@ defineExpose(drawerApi);
type="info"
/>
<div class="mb-2 flex gap-2 px-1 text-xs text-muted-foreground">
<span class="w-[34%]">规格名称</span>
<span class="w-[28%]">尺寸</span>
<span class="w-[28%]">常规价</span>
<span class="w-[28%]">规格名称</span>
<span class="w-[22%]">尺寸</span>
<span class="w-[22%]">常规价</span>
<span class="w-[16%]">库存(-1不限)</span>
<span class="w-[10%]">操作</span>
</div>
<div class="flex flex-col gap-2">
@@ -111,19 +113,20 @@ defineExpose(drawerApi);
>
<Input
v-model:value="row.specification"
class="w-[34%]"
class="w-[28%]"
placeholder="如 三人位"
/>
<Input
v-model:value="row.dimension"
class="w-[28%]"
class="w-[22%]"
placeholder="如 2200*950*750"
/>
<Input
v-model:value="row.routine"
class="w-[28%]"
class="w-[22%]"
placeholder="如 3800"
/>
<InputNumber v-model:value="row.stock" class="w-[16%]" :min="-1" />
<Button
class="w-[10%]"
danger

View File

@@ -81,6 +81,14 @@ export const modalFormProps: VbenFormProps = {
formItemClass: 'col-span-12',
label: '图册 PDF',
},
{
component: 'UploadDoc',
componentProps: { accept: '.mp4,.mov,.webm' },
fieldName: 'video',
formItemClass: 'col-span-12',
label: '商品视频',
help: '小程序详情页可播放;走文档上传接口,建议 mp4',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-12',

View File

@@ -8,14 +8,7 @@
*/
import type { ProductRecord } from '#/components/product';
import {
computed,
nextTick,
onMounted,
onUnmounted,
ref,
watch,
} from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { preferences } from '@vben/preferences';
@@ -30,12 +23,19 @@ import {
Segmented,
Switch,
Table,
Upload,
} from 'ant-design-vue';
import { requestClient } from '#/api/request';
import { toImageList } from '#/components/crud';
import { Icon } from '#/components/icon';
import BrandAlbumLoading from '#/components/product/brand-album-loading.vue';
import { TableAction } from '#/components/table-action';
import {
downloadStyledExcel,
parseExcelWorkbook,
pickExcelSheet,
} from '#/util/excel';
import { thumbUrl, watermarkUrl } from '#/util/media';
import GoodsCategoryTree from '#/views/goods/components/category-tree.vue';
@@ -73,7 +73,7 @@ const CARD_WIDTH = 240;
const waterfallRef = ref<HTMLElement | null>(null);
let columnHeights: number[] = [];
let lastRenderedIndex = 0;
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
let resizeTimer: null | ReturnType<typeof setTimeout> = null;
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: CatalogueFormModal,
@@ -103,7 +103,7 @@ const listParams = computed(() => ({
}));
const tableColumns = [
{ title: '封面', dataIndex: 'cover', key: 'cover', width: 110 },
{ title: '封面', dataIndex: 'cover', key: 'cover', width: 130 },
{ title: '编号', dataIndex: 'identifier', key: 'identifier', width: 120 },
{ title: '介绍', dataIndex: 'alias', key: 'alias', ellipsis: true },
{
@@ -145,7 +145,7 @@ function onCategorySelect(id: number | undefined) {
categoryId.value = id;
}
function onModeChange(value: string | number) {
function onModeChange(value: number | string) {
const next = String(value) as ShowType;
showType.value = next;
localStorage.setItem(STORAGE_KEY, next);
@@ -158,7 +158,7 @@ function onModeChange(value: string | number) {
}
}
function onStatusFilterChange(value: string | number) {
function onStatusFilterChange(value: number | string) {
const raw = String(value);
statusFilter.value = raw === 'all' ? undefined : Number(raw);
}
@@ -174,6 +174,7 @@ function toFormValues(row?: ProductRecord) {
id: row.id,
identifier: row.identifier,
pdf: row.pdf ?? '',
video: row.video ?? '',
price: row.price,
status: row.status ?? 0,
title: row.title,
@@ -211,6 +212,42 @@ async function toggleStatus(row: ProductRecord, enabled: boolean) {
row.status = enabled ? 0 : 1;
}
/** 导出商品 xlsxExcelJS 美化) */
async function exportCatalogue() {
const res = await requestClient.get<any>('catalogue/export');
await downloadStyledExcel(res);
message.success('已导出');
}
/** 导入商品 xlsx第一张商品第二张报价单按编号挂回去 */
async function importCatalogue(file: File) {
try {
const book = await parseExcelWorkbook(file);
const rows = pickExcelSheet(book, ['商品', '商品图册']);
const productRows = rows.length > 0 ? rows : (book[0]?.rows ?? []);
const priceSheets = pickExcelSheet(book, ['报价单', '关联报价单', '规格']);
if (productRows.length === 0 && priceSheets.length === 0) {
message.warning('表格没有数据行');
return false;
}
const res = await requestClient.post<any>('catalogue/import', {
price_sheets: priceSheets,
rows: productRows,
});
const priceHint =
(res?.price_created ?? 0) + (res?.price_updated ?? 0) > 0
? `;报价单新增 ${res?.price_created ?? 0},更新 ${res?.price_updated ?? 0}`
: '';
message.success(
`导入完成:新增 ${res?.created ?? 0},更新 ${res?.updated ?? 0}${priceHint}`,
);
refresh();
} catch (error: any) {
message.error(error?.message || '导入失败');
}
return false;
}
async function loadList() {
listLoading.value = true;
try {
@@ -438,10 +475,12 @@ onUnmounted(() => {
allow-clear
class="w-[240px]"
placeholder="搜索商品名称 / 编号"
@change="(e: any) => (e.target.value ? undefined : applyKeyword(''))"
@change="
(e: any) => (e.target.value ? undefined : applyKeyword(''))
"
@search="applyKeyword"
/>
<div class="ml-auto">
<div class="ml-auto flex items-center gap-2">
<TableAction
:actions="[
{
@@ -450,8 +489,20 @@ onUnmounted(() => {
icon: 'ant-design:plus-outlined',
onClick: () => showModal(),
},
{
label: '导出',
icon: 'ant-design:download-outlined',
onClick: () => exportCatalogue(),
},
]"
/>
<Upload
:before-upload="importCatalogue"
:show-upload-list="false"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
>
<Button>导入 Excel</Button>
</Upload>
</div>
</div>
@@ -509,7 +560,9 @@ onUnmounted(() => {
<div>{{ row.routine ?? '-' }}</div>
</div>
<div
v-if="!record.price_sheet || record.price_sheet.length === 0"
v-if="
!record.price_sheet || record.price_sheet.length === 0
"
class="price-grid__empty"
>
暂无规格报价
@@ -598,7 +651,11 @@ onUnmounted(() => {
{{ record.identifier || record.title || '未命名' }}
</div>
<div class="waterfall-card__actions" @click.stop>
<Button size="small" type="link" @click="showModal(record, true)">
<Button
size="small"
type="link"
@click="showModal(record, true)"
>
编辑
</Button>
<Button size="small" type="link" @click="openAlbum(record)">
@@ -623,20 +680,20 @@ onUnmounted(() => {
<style scoped>
.catalogue-cover {
height: 72px;
width: 72px;
width: 96px;
height: 96px;
cursor: pointer;
border-radius: 8px;
object-fit: cover;
border-radius: 8px;
}
.price-grid {
min-width: 420px;
overflow: hidden;
font-size: 13px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--card, var(--background)));
font-size: 13px;
}
.price-grid__header,
@@ -647,16 +704,16 @@ onUnmounted(() => {
}
.price-grid__header {
background: hsl(var(--muted) / 0.35);
font-weight: 600;
color: hsl(var(--foreground));
background: hsl(var(--muted) / 35%);
}
.price-grid__header > div,
.price-grid__row > div {
padding: 6px 10px;
border-right: 1px solid hsl(var(--border));
word-break: break-word;
border-right: 1px solid hsl(var(--border));
}
.price-grid__header > div:last-child,
@@ -665,8 +722,8 @@ onUnmounted(() => {
}
.price-grid__row {
border-top: 1px solid hsl(var(--border));
color: hsl(var(--muted-foreground));
border-top: 1px solid hsl(var(--border));
}
.price-grid__empty {
@@ -690,9 +747,9 @@ onUnmounted(() => {
width: 240px;
overflow: hidden;
cursor: pointer;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 12px;
background: hsl(var(--card, var(--background)));
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
@@ -700,14 +757,14 @@ onUnmounted(() => {
}
.waterfall-card:hover {
border-color: hsl(var(--primary) / 0.45);
box-shadow: 0 10px 28px hsl(var(--foreground) / 0.08);
border-color: hsl(var(--primary) / 45%);
box-shadow: 0 10px 28px hsl(var(--foreground) / 8%);
}
.waterfall-card__img {
width: 100%;
overflow: hidden;
background: hsl(var(--muted) / 0.35);
background: hsl(var(--muted) / 35%);
}
.waterfall-card__img img {
@@ -718,9 +775,9 @@ onUnmounted(() => {
.waterfall-card__empty {
display: flex;
height: 160px;
align-items: center;
justify-content: center;
height: 160px;
color: hsl(var(--muted-foreground));
}
@@ -730,9 +787,9 @@ onUnmounted(() => {
.waterfall-card__id {
overflow: hidden;
color: hsl(var(--primary));
font-weight: 600;
text-overflow: ellipsis;
font-weight: 600;
color: hsl(var(--primary));
white-space: nowrap;
}

View File

@@ -11,7 +11,7 @@ export const gridOptions = createGridOptions({
{ width: 50, treeNode: true },
{ field: 'id', align: 'left', title: 'ID', width: 90 },
{ field: 'name', align: 'left', title: '分类名称', minWidth: 200 },
{ field: 'url', title: '分类图', width: 100, slots: { default: 'image' } },
{ field: 'url', title: '分类图', width: 120, slots: { default: 'image' } },
{ field: 'sort', title: '排序', width: 90 },
statusColumn,
{ field: 'created_at', title: '创建时间', width: 180 },

View File

@@ -14,6 +14,7 @@ import { gridOptions } from './config/table';
const {
FormModal,
Grid,
gridApi,
hasSelection,
removeRows,
showModal,
@@ -38,13 +39,23 @@ const {
function addChild(row: Record<string, any>) {
showModal({ pid: row.id, sort: 0, status: 0 }, false);
}
/** 一键展开整棵分类树,方便一次看清层级 */
function expandAll() {
gridApi.grid?.setAllTreeExpand(true);
}
/** 一键收起,只留根节点 */
function collapseAll() {
gridApi.grid?.setAllTreeExpand(false);
}
</script>
<template>
<Page auto-content-height title="商品分类">
<FormModal />
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{
@@ -53,6 +64,14 @@ function addChild(row: Record<string, any>) {
icon: 'ant-design:plus-outlined',
onClick: () => showModal(),
},
{
label: '一键展开',
onClick: expandAll,
},
{
label: '一键收起',
onClick: collapseAll,
},
{
label: '删除',
danger: true,
@@ -67,7 +86,7 @@ function addChild(row: Record<string, any>) {
/>
</template>
<template #image="{ row }">
<Image v-if="row.url" :height="40" :src="row.url" :width="40" />
<Image v-if="row.url" :height="72" :src="row.url" :width="72" />
<span v-else>-</span>
</template>
<template #status="{ row }">

View File

@@ -9,7 +9,7 @@ import { imageApi } from '../api';
export const gridOptions = createGridOptions({
columns: [
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{ field: 'url', title: '图片', width: 100, slots: { default: 'image' } },
{ field: 'url', title: '图片', width: 120, slots: { default: 'image' } },
{ field: 'catalogue_id', title: '商品ID', width: 100 },
{ field: 'type', title: '类型', width: 110, slots: { default: 'type' } },
statusColumn,

View File

@@ -32,7 +32,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
<Page auto-content-height title="商品相册">
<FormModal />
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{
@@ -55,7 +55,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
/>
</template>
<template #image="{ row }">
<Image v-if="row.url" :height="40" :src="row.url" :width="40" />
<Image v-if="row.url" :height="72" :src="row.url" :width="72" />
<span v-else>-</span>
</template>
<template #type="{ row }">

View File

@@ -0,0 +1,51 @@
import { requestClient } from '#/api/request';
import { createCrudApi } from '#/components/crud';
const prefix = 'package/';
/** 套餐基础 CRUD */
export const packageApi = createCrudApi('package');
/**
* 保存搭配明细。package_amount 按元提交,后端转分并重算原价。
*/
export function savePackageItems(data: {
id: number;
items: Record<string, any>[];
package_amount?: number;
}) {
return requestClient.post<any>(`${prefix}save-items`, data);
}
export interface PackageCatalogSheet {
dimension: string;
id: number;
selectable: boolean;
specification: string;
unit_price: number;
unit_price_text: string;
}
export interface PackageCatalogItem {
cover: string;
id: number;
identifier: string;
sheets: PackageCatalogSheet[];
title: string;
}
/**
* 搭配搜索:商品封面 + 规格。selectable=false 的规格前端不可点。
*/
export async function searchPackageCatalog(params: {
keyword: string;
limit?: number;
}): Promise<PackageCatalogItem[]> {
const res = await requestClient.get<any>(`${prefix}search-catalog`, {
params: {
keyword: params.keyword,
limit: params.limit ?? 20,
},
});
return res?.items ?? [];
}

View File

@@ -0,0 +1,296 @@
<script lang="ts" setup>
/**
* 套餐搭配搜索:在抽屉里直接展开卡片,不走 Popover 挂到 body。
* Vben 抽屉会拦截外部 pointerdown挂到 body 的浮层看起来能点、实际点不到。
*/
import type { PackageCatalogItem, PackageCatalogSheet } from '../api';
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { Empty, Input, Spin } from 'ant-design-vue';
import { Icon } from '#/components/icon';
import { thumbUrl } from '#/util/media';
import { searchPackageCatalog } from '../api';
defineOptions({
name: 'PackageCatalogPicker',
});
const props = withDefaults(
defineProps<{
/** 点规格时带上的数量,由外层数量框控制 */
quantity?: number;
}>(),
{ quantity: 1 },
);
const emit = defineEmits<{
pick: [
payload: {
catalogue_id: number;
cover: string;
price_sheet_id: number;
quantity: number;
specification: string;
title: string;
unit_yuan: number;
},
];
}>();
const rootRef = ref<HTMLElement | null>(null);
const open = ref(false);
const keyword = ref('');
const loading = ref(false);
const items = ref<PackageCatalogItem[]>([]);
const activeIndex = ref(-1);
let timer: ReturnType<typeof setTimeout> | undefined;
let controller: AbortController | undefined;
/** 远程搜商品,过期请求用 AbortController 丢掉 */
async function fetchItems() {
controller?.abort();
controller = new AbortController();
const signal = controller.signal;
loading.value = true;
try {
const list = await searchPackageCatalog({
keyword: keyword.value.trim(),
limit: 20,
});
if (signal.aborted) {
return;
}
items.value = list;
activeIndex.value = list.length > 0 ? 0 : -1;
} finally {
if (!signal.aborted) {
loading.value = false;
}
}
}
function scheduleFetch() {
open.value = true;
clearTimeout(timer);
timer = setTimeout(fetchItems, 300);
}
function openPanel() {
open.value = true;
if (items.value.length === 0 && !loading.value) {
fetchItems();
}
}
function closePanel() {
open.value = false;
activeIndex.value = -1;
}
function sheetLabel(sheet: PackageCatalogSheet) {
const spec = sheet.specification || '规格';
return sheet.dimension ? `${spec} · ${sheet.dimension}` : spec;
}
/** 只有 selectable 的规格才能加入,避免再撞上「缺少有效报价」 */
function pickSheet(item: PackageCatalogItem, sheet: PackageCatalogSheet) {
if (!sheet.selectable) {
return;
}
emit('pick', {
catalogue_id: item.id,
cover: item.cover || '',
price_sheet_id: sheet.id,
quantity: Math.max(1, Number(props.quantity || 1)),
specification: sheetLabel(sheet),
title: item.identifier ? `${item.title} / ${item.identifier}` : item.title,
unit_yuan: Number(sheet.unit_price_text || 0),
});
}
function firstSelectable(item?: PackageCatalogItem) {
return item?.sheets.find((sheet) => sheet.selectable);
}
function move(step: number) {
const total = items.value.length;
if (total === 0) {
return;
}
activeIndex.value = (activeIndex.value + step + total) % total;
nextTick(() => {
rootRef.value
?.querySelector(`[data-package-catalog="${activeIndex.value}"]`)
?.scrollIntoView({ block: 'nearest' });
});
}
function onKeydown(event: KeyboardEvent) {
if (!open.value && ['ArrowDown', 'Enter'].includes(event.key)) {
openPanel();
return;
}
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
move(1);
break;
}
case 'ArrowUp': {
event.preventDefault();
move(-1);
break;
}
case 'Enter': {
if (event.isComposing) {
return;
}
event.preventDefault();
const item = items.value[activeIndex.value];
const sheet = firstSelectable(item);
if (item && sheet) {
pickSheet(item, sheet);
}
break;
}
case 'Escape': {
closePanel();
break;
}
default: {
break;
}
}
}
/** 点在搜索卡片外面再收起,点规格加入后保持展开方便连加 */
function onDocumentPointerDown(event: PointerEvent) {
const target = event.target as Node | null;
if (rootRef.value && target && !rootRef.value.contains(target)) {
closePanel();
}
}
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown, true);
});
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
clearTimeout(timer);
controller?.abort();
});
</script>
<template>
<div ref="rootRef" class="w-full">
<div class="flex items-center gap-2">
<Input
v-model:value="keyword"
class="flex-1"
placeholder="搜索商品名称 / 编号"
@focus="openPanel"
@input="scheduleFetch"
@keydown="onKeydown"
>
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
</Input>
<slot name="suffix"></slot>
</div>
<div
v-if="open"
class="mt-2 rounded-lg border border-border bg-background p-2 shadow-md"
>
<Spin :spinning="loading">
<div v-if="items.length === 0" class="py-6">
<Empty
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="没有匹配的商品"
/>
</div>
<div v-else class="flex max-h-[420px] flex-col gap-2 overflow-y-auto">
<div
v-for="(item, index) in items"
:key="item.id"
class="flex gap-3 rounded-lg border p-2 transition-colors"
:class="[
index === activeIndex
? 'border-primary bg-primary/5'
: 'border-border bg-background',
]"
:data-package-catalog="index"
@mouseenter="activeIndex = index"
>
<div class="w-[132px] shrink-0">
<img
v-if="item.cover"
:alt="item.title"
:src="thumbUrl(item.cover, 240)"
class="mb-1.5 h-[88px] w-full rounded-md object-cover"
/>
<div
v-else
class="mb-1.5 flex h-[88px] items-center justify-center rounded-md bg-muted text-xs text-muted-foreground"
>
无封面
</div>
<div class="truncate text-sm font-medium" :title="item.title">
{{ item.title }}
</div>
<div class="truncate text-xs text-muted-foreground">
{{ item.identifier || '无编号' }}
</div>
</div>
<div class="min-w-0 flex-1 space-y-1.5">
<div
v-if="item.sheets.length === 0"
class="py-4 text-xs text-muted-foreground"
>
暂无规格
</div>
<button
v-for="sheet in item.sheets"
:key="sheet.id"
type="button"
:disabled="!sheet.selectable"
class="flex w-full items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-left text-sm transition-colors"
:class="[
sheet.selectable
? 'cursor-pointer border-border hover:border-primary hover:bg-primary/10'
: 'cursor-not-allowed border-border/60 bg-muted/40 text-muted-foreground',
]"
:title="
sheet.selectable ? '点击加入套餐' : '缺少有效报价,不可加入'
"
@click="pickSheet(item, sheet)"
>
<span class="min-w-0 truncate">{{ sheetLabel(sheet) }}</span>
<span
class="shrink-0 text-xs"
:class="[
sheet.selectable
? 'font-medium text-primary'
: 'text-muted-foreground',
]"
>
{{
sheet.selectable ? `¥${sheet.unit_price_text}` : '无报价'
}}
</span>
</button>
</div>
</div>
</div>
</Spin>
<div class="mt-2 text-xs text-muted-foreground">
点右侧规格加入 · 无报价不可选 · 切换商品回车加入第一条有效规格
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,228 @@
<script lang="ts" setup>
/**
* 套餐搭配抽屉:气泡卡片选商品+规格,点报价单直接加入。
* 改一行立刻重算原价,套餐报价按原价差额自动补,仍可手改。
*/
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { Button, Image, InputNumber, message, Table } from 'ant-design-vue';
import { popupData } from '#/components/crud';
import { thumbUrl } from '#/util/media';
import { packageApi, savePackageItems } from '../api';
import CatalogPicker from './catalog-picker.vue';
defineOptions({
name: 'PackageItemsDrawer',
});
interface ItemRow {
catalogue_id: number;
cover: string;
material_key: string;
price_sheet_id: number;
quantity: number;
specification: string;
title: string;
unit_yuan: number;
}
const record = ref<Record<string, any>>({});
const items = ref<ItemRow[]>([]);
const quoteYuan = ref(0);
const lastOriginal = ref(0);
const pickQty = ref(1);
const originalYuan = computed(() =>
items.value.reduce((sum, row) => sum + row.unit_yuan * row.quantity, 0),
);
const discountYuan = computed(() =>
Math.max(0, originalYuan.value - quoteYuan.value),
);
const columns = [
{ title: '封面', key: 'cover', width: 72 },
{ title: '商品', dataIndex: 'title' },
{ title: '规格', dataIndex: 'specification', width: 180 },
{ title: '单价', key: 'unit', width: 90 },
{ title: '数量', key: 'qty', width: 110 },
{ title: '操作', key: 'action', width: 70 },
];
const [Drawer, drawerApi] = useVbenDrawer({
async onConfirm() {
if (items.value.some((row) => !row.price_sheet_id || row.unit_yuan <= 0)) {
message.warning('存在缺少有效报价的规格,请删掉后再保存');
return;
}
drawerApi.lock();
try {
await savePackageItems({
id: Number(record.value.id),
items: items.value.map((row, index) => ({
catalogue_id: row.catalogue_id,
price_sheet_id: row.price_sheet_id,
material_key: row.material_key,
quantity: row.quantity,
sort: index,
})),
package_amount: Number(quoteYuan.value.toFixed(2)),
});
message.success('搭配已保存');
popupData(drawerApi).gridApi?.reload();
drawerApi.close();
} finally {
drawerApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
record.value = popupData(drawerApi).record ?? {};
drawerApi.setState({ loading: true });
try {
const detail = await packageApi.detail(record.value.id);
items.value = (detail.items || []).map((row: any) => ({
catalogue_id: Number(row.catalogue_id),
cover: row.cover || '',
material_key: row.material_key || 'routine',
price_sheet_id: Number(row.price_sheet_id),
quantity: Number(row.quantity || 1),
specification: row.specification || '',
title: row.title || '',
unit_yuan: Number(row.unit_price_text || 0),
}));
lastOriginal.value = originalYuan.value;
quoteYuan.value = Number(detail.package_amount_text || 0);
} finally {
drawerApi.setState({ loading: false });
}
},
});
/** 原价变了就把差额补到套餐报价上 */
function applyOriginalDiff() {
const next = originalYuan.value;
quoteYuan.value = Math.max(
0,
Number((quoteYuan.value + (next - lastOriginal.value)).toFixed(2)),
);
lastOriginal.value = next;
}
function changeQty(index: number, qty: number) {
const row = items.value[index];
if (!row) return;
row.quantity = Math.max(1, qty);
applyOriginalDiff();
}
function removeItem(index: number) {
items.value.splice(index, 1);
applyOriginalDiff();
}
/** 同一规格再点一次就加数量,避免表格里堆重复行 */
function addPicked(payload: {
catalogue_id: number;
cover: string;
price_sheet_id: number;
quantity: number;
specification: string;
title: string;
unit_yuan: number;
}) {
if (payload.unit_yuan <= 0) {
message.warning('该规格缺少有效报价');
return;
}
const exist = items.value.find(
(row) => row.price_sheet_id === payload.price_sheet_id,
);
if (exist) {
exist.quantity += Math.max(1, payload.quantity);
applyOriginalDiff();
return;
}
items.value.push({
catalogue_id: payload.catalogue_id,
cover: payload.cover,
material_key: 'routine',
price_sheet_id: payload.price_sheet_id,
quantity: Math.max(1, payload.quantity),
specification: payload.specification,
title: payload.title,
unit_yuan: payload.unit_yuan,
});
applyOriginalDiff();
}
</script>
<template>
<Drawer class="w-[920px]" title="搭配单品">
<div class="mb-4 grid grid-cols-3 gap-3 text-sm">
<div class="rounded border border-border p-3">
<div class="text-muted-foreground">原价自动</div>
<div class="text-lg font-semibold">¥{{ originalYuan.toFixed(2) }}</div>
</div>
<div class="rounded border border-border p-3">
<div class="text-muted-foreground">套餐报价可改</div>
<InputNumber
v-model:value="quoteYuan"
:min="0"
:precision="2"
class="w-full"
/>
</div>
<div class="rounded border border-border p-3">
<div class="text-muted-foreground">优惠</div>
<div class="text-lg font-semibold text-primary">
¥{{ discountYuan.toFixed(2) }}
</div>
</div>
</div>
<div class="mb-3">
<CatalogPicker :quantity="pickQty" @pick="addPicked">
<template #suffix>
<InputNumber v-model:value="pickQty" :min="1" class="w-[90px]" />
</template>
</CatalogPicker>
</div>
<Table
:columns="columns"
:data-source="items"
:pagination="false"
:row-key="(_row: ItemRow, index?: number) => String(index)"
size="small"
>
<template #bodyCell="{ column, record: row, index }">
<template v-if="column.key === 'cover'">
<Image
v-if="row.cover"
:height="40"
:src="thumbUrl(row.cover, 80)"
:width="40"
/>
<span v-else class="text-muted-foreground">-</span>
</template>
<template v-else-if="column.key === 'unit'">
¥{{ Number(row.unit_yuan).toFixed(2) }}
</template>
<template v-else-if="column.key === 'qty'">
<InputNumber
:min="1"
:value="row.quantity"
@change="(val: any) => changeQty(index, Number(val || 1))"
/>
</template>
<template v-else-if="column.key === 'action'">
<Button danger type="link" @click="removeItem(index)">删除</Button>
</template>
</template>
</Table>
</Drawer>
</template>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
/**
* 套餐新增/编辑弹窗。报价按元提交,封面取第一张。
*/
import { firstImage, useCrudModal } from '#/components/crud';
import { packageApi } from '../api';
import { modalFormProps } from '../config/form';
defineOptions({
name: 'PackageFormModal',
});
const { Form, isUpdate, Modal } = useCrudModal({
api: packageApi,
formProps: modalFormProps,
transform: (values) => ({
...values,
cover: firstImage(values.cover),
}),
});
</script>
<template>
<Modal :title="`${isUpdate ? '编辑' : '新增'}套餐`" class="w-[40%]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,11 @@
/** 上架状态0=上架 1=下架,与其它业务模块一致 */
export const PACKAGE_STATUS_OPTIONS = [
{ label: '上架', value: 0 },
{ label: '下架', value: 1 },
];
/** 是否首页热门 */
export const HOT_OPTIONS = [
{ label: '热门', value: 1 },
{ label: '普通', value: 0 },
];

View File

@@ -0,0 +1,86 @@
import type { VbenFormProps } from '#/adapter/form';
import { PACKAGE_STATUS_OPTIONS } from './constants';
/**
* 套餐主表单。报价按元录入,提交时字段名仍是 package_amount后端转分。
*/
export const modalFormProps: VbenFormProps = {
commonConfig: {
componentProps: { class: 'w-full' },
formItemClass: 'col-span-12',
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
dependencies: { show: false, triggerFields: ['id'] },
fieldName: 'id',
label: 'ID',
},
{
component: 'VbenInput',
componentProps: { placeholder: '套餐名称' },
fieldName: 'name',
label: '名称',
rules: 'required',
},
{
component: 'UploadImage',
componentProps: { maxCount: 1, multiple: false },
defaultValue: [],
fieldName: 'cover',
label: '封面',
},
{
component: 'VbenInput',
componentProps: { placeholder: '副标题,如 客厅四件套' },
fieldName: 'subtitle',
label: '副标题',
},
{
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
placeholder: '独立套餐报价,单位元',
},
fieldName: 'package_amount',
label: '套餐报价',
},
{
component: 'InputNumber',
componentProps: { min: 0, placeholder: '数字越小越靠前' },
defaultValue: 0,
fieldName: 'sort',
label: '排序',
},
{
component: 'Select',
componentProps: {
options: [
{ label: '普通', value: 0 },
{ label: '首页热门', value: 1 },
],
},
defaultValue: 0,
fieldName: 'is_hot',
label: '热门',
},
{
component: 'Select',
componentProps: { options: PACKAGE_STATUS_OPTIONS },
defaultValue: 0,
fieldName: 'status',
label: '状态',
},
{
component: 'VbenTextarea',
componentProps: { rows: 2, placeholder: '备注' },
fieldName: 'remark',
label: '备注',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-12',
};

View File

@@ -0,0 +1,39 @@
import type { VbenFormProps } from '#/adapter/form';
import { HOT_OPTIONS, PACKAGE_STATUS_OPTIONS } from './constants';
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'VbenInput',
componentProps: { placeholder: '套餐名称' },
fieldName: 'name',
label: '名称',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: HOT_OPTIONS,
placeholder: '是否热门',
},
fieldName: 'is_hot',
label: '热门',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: PACKAGE_STATUS_OPTIONS,
placeholder: '状态',
},
fieldName: 'status',
label: '状态',
},
],
showCollapseButton: true,
submitButtonOptions: { content: '查询' },
submitOnChange: true,
submitOnEnter: false,
};

View File

@@ -0,0 +1,25 @@
import {
actionColumn,
createGridOptions,
statusColumn,
} from '#/components/crud';
import { packageApi } from '../api';
export const gridOptions = createGridOptions({
columns: [
{ field: 'id', align: 'left', title: 'ID', width: 70 },
{ field: 'cover', title: '封面', width: 140, slots: { default: 'cover' } },
{ field: 'name', align: 'left', title: '名称', minWidth: 160 },
{ field: 'item_count', title: '件数', width: 70 },
{ field: 'original_amount_text', title: '原价', width: 110 },
{ field: 'package_amount_text', title: '套餐价', width: 110 },
{ field: 'discount_amount_text', title: '优惠', width: 110 },
{ field: 'is_hot', title: '热门', width: 90, slots: { default: 'hot' } },
{ field: 'sort', title: '排序', width: 80 },
statusColumn,
{ field: 'created_at', title: '创建时间', width: 170 },
actionColumn(220),
],
query: (params) => packageApi.list(params),
});

View File

@@ -0,0 +1,145 @@
<script lang="ts" setup>
/**
* 套餐搭配列表:封面、原价/套餐价/优惠、热门与上下架。
*/
import { Page, useVbenDrawer } from '@vben/common-ui';
import { Image, Switch } from 'ant-design-vue';
import { toImageList, useCrudTable } from '#/components/crud';
import { TableAction } from '#/components/table-action';
import { packageApi } from './api';
import PackageItemsDrawer from './components/items-drawer.vue';
import PackageFormModal from './components/modal.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
defineOptions({ name: 'GoodsPackage' });
const {
FormModal,
Grid,
gridApi,
hasSelection,
removeRows,
showModal,
toggleStatus,
} = useCrudTable({
api: packageApi,
defaultValues: { sort: 0, status: 0, is_hot: 0, package_amount: 0 },
formOptions,
gridOptions,
modalComponent: PackageFormModal,
toFormValues: (row) => ({
id: row.id,
name: row.name,
subtitle: row.subtitle,
remark: row.remark,
cover: toImageList(row.cover),
package_amount: Number(row.package_amount_text || 0),
sort: row.sort ?? 0,
status: row.status ?? 0,
is_hot: row.is_hot ?? 0,
}),
});
const [ItemsDrawer, itemsDrawerApi] = useVbenDrawer({
connectedComponent: PackageItemsDrawer,
});
function openItems(row: Record<string, any>) {
itemsDrawerApi.setData({ record: row, gridApi });
itemsDrawerApi.open();
}
/** 热门开关单独改,避免走上下架 */
async function toggleHot(row: Record<string, any>, checked: boolean) {
await packageApi.update({
id: row.id,
name: row.name,
is_hot: checked ? 1 : 0,
});
gridApi.query();
}
</script>
<template>
<Page auto-content-height title="套餐搭配">
<FormModal />
<ItemsDrawer />
<Grid>
<template #toolbar-actions>
<TableAction
:actions="[
{
label: '新增套餐',
type: 'primary',
icon: 'ant-design:plus-outlined',
onClick: () => showModal(),
},
{
label: '删除',
danger: true,
icon: 'ant-design:delete-outlined',
disabled: !hasSelection,
popConfirm: {
title: '确定删除勾选的套餐吗?',
confirm: () => removeRows(),
},
},
]"
/>
</template>
<template #cover="{ row }">
<Image v-if="row.cover" :height="60" :src="row.cover" :width="96" />
<span v-else>-</span>
</template>
<template #hot="{ row }">
<Switch
:checked="row.is_hot === 1"
checked-children="热门"
un-checked-children="普通"
@change="(checked: any) => toggleHot(row, !!checked)"
/>
</template>
<template #status="{ row }">
<Switch
:checked="row.status === 0"
checked-children="上架"
un-checked-children="下架"
@change="(checked: any) => toggleStatus(row, !!checked)"
/>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '搭配',
type: 'link',
size: 'small',
onClick: () => openItems(row),
},
{
label: '编辑',
type: 'link',
icon: 'uil:edit',
size: 'small',
onClick: () => showModal(row, true),
},
{
label: '删除',
danger: true,
type: 'link',
size: 'small',
popConfirm: {
title: '确定删除该套餐吗?',
confirm: () => removeRows(row.id),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -39,7 +39,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
/>
<FormModal />
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{

View File

@@ -7,14 +7,7 @@
*/
import type { ProductRecord } from '#/components/product';
import {
computed,
nextTick,
onMounted,
onUnmounted,
ref,
watch,
} from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { preferences } from '@vben/preferences';
@@ -64,7 +57,7 @@ const waterfallRef = ref<HTMLElement | null>(null);
const scrollRef = ref<HTMLElement | null>(null);
let columnHeights: number[] = [];
let lastRenderedIndex = 0;
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
let resizeTimer: null | ReturnType<typeof setTimeout> = null;
const [SpecDrawer, specDrawerApi] = useVbenDrawer({
connectedComponent: CatalogueSpecDrawer,
@@ -86,7 +79,7 @@ const tableColumns = [
title: '封面',
dataIndex: 'cover',
key: 'cover',
width: 110,
width: 130,
},
{
title: '编号',
@@ -122,7 +115,7 @@ function onCategorySelect(id: number | undefined) {
categoryId.value = id;
}
function onModeChange(value: string | number) {
function onModeChange(value: number | string) {
const next = String(value) as ShowType;
showType.value = next;
localStorage.setItem(STORAGE_KEY, next);
@@ -360,7 +353,9 @@ onUnmounted(() => {
style="display: none"
/>
<div class="quote-page border-border bg-card flex min-h-0 flex-1 gap-3 overflow-hidden rounded-lg border p-3">
<div
class="quote-page border-border bg-card flex min-h-0 flex-1 gap-3 overflow-hidden rounded-lg border p-3"
>
<div class="hidden w-[220px] shrink-0 md:block">
<GoodsCategoryTree class="h-full" @select="onCategorySelect" />
</div>
@@ -377,7 +372,9 @@ onUnmounted(() => {
allow-clear
class="w-[240px]"
placeholder="搜索商品名称 / 编号"
@change="(e: any) => (e.target.value ? undefined : applyKeyword(''))"
@change="
(e: any) => (e.target.value ? undefined : applyKeyword(''))
"
@search="applyKeyword"
/>
</div>
@@ -416,7 +413,9 @@ onUnmounted(() => {
</Button>
</template>
<template v-else-if="column.key === 'alias'">
<span :title="record.alias">{{ record.alias || record.title || '-' }}</span>
<span :title="record.alias">{{
record.alias || record.title || '-'
}}</span>
</template>
<template v-else-if="column.key === 'price_sheet'">
<div class="price-grid">
@@ -435,7 +434,9 @@ onUnmounted(() => {
<div>{{ row.routine ?? '-' }}</div>
</div>
<div
v-if="!record.price_sheet || record.price_sheet.length === 0"
v-if="
!record.price_sheet || record.price_sheet.length === 0
"
class="price-grid__empty"
>
暂无规格报价
@@ -473,7 +474,10 @@ onUnmounted(() => {
class="waterfall-card"
@click="openSpec(record)"
>
<div class="waterfall-card__img" @click.stop="openPreview(record)">
<div
class="waterfall-card__img"
@click.stop="openPreview(record)"
>
<img
v-if="record.cover"
:alt="record.title"
@@ -507,20 +511,20 @@ onUnmounted(() => {
<style scoped>
.quote-cover {
height: 72px;
width: 72px;
width: 96px;
height: 96px;
cursor: pointer;
border-radius: 8px;
object-fit: cover;
border-radius: 8px;
}
.price-grid {
min-width: 420px;
overflow: hidden;
font-size: 13px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--card, var(--background)));
font-size: 13px;
}
.price-grid__header,
@@ -531,16 +535,16 @@ onUnmounted(() => {
}
.price-grid__header {
background: hsl(var(--muted) / 0.35);
font-weight: 600;
color: hsl(var(--foreground));
background: hsl(var(--muted) / 35%);
}
.price-grid__header > div,
.price-grid__row > div {
padding: 6px 10px;
border-right: 1px solid hsl(var(--border));
word-break: break-word;
border-right: 1px solid hsl(var(--border));
}
.price-grid__header > div:last-child,
@@ -549,8 +553,8 @@ onUnmounted(() => {
}
.price-grid__row {
border-top: 1px solid hsl(var(--border));
color: hsl(var(--muted-foreground));
border-top: 1px solid hsl(var(--border));
}
.price-grid__empty {
@@ -574,9 +578,9 @@ onUnmounted(() => {
width: 240px;
overflow: hidden;
cursor: pointer;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 12px;
background: hsl(var(--card, var(--background)));
transition:
box-shadow 0.2s ease,
border-color 0.2s ease,
@@ -585,14 +589,14 @@ onUnmounted(() => {
}
.waterfall-card:hover {
border-color: hsl(var(--primary) / 0.45);
box-shadow: 0 10px 28px hsl(var(--foreground) / 0.08);
border-color: hsl(var(--primary) / 45%);
box-shadow: 0 10px 28px hsl(var(--foreground) / 8%);
}
.waterfall-card__img {
width: 100%;
overflow: hidden;
background: hsl(var(--muted) / 0.35);
background: hsl(var(--muted) / 35%);
}
.waterfall-card__img img {
@@ -603,9 +607,9 @@ onUnmounted(() => {
.waterfall-card__empty {
display: flex;
height: 160px;
align-items: center;
justify-content: center;
height: 160px;
color: hsl(var(--muted-foreground));
}
@@ -615,9 +619,9 @@ onUnmounted(() => {
.waterfall-card__id {
overflow: hidden;
color: hsl(var(--primary));
font-weight: 600;
text-overflow: ellipsis;
font-weight: 600;
color: hsl(var(--primary));
white-space: nowrap;
}
</style>

View File

@@ -15,7 +15,8 @@ export interface MaterialItem {
/** 被业务表引用的次数0 表示可回收 */
ref_count: number;
size: number;
type: string;
/** 后端存 0~6兼容历史字符串 */
type: number | string;
url: string;
width: number;
}
@@ -31,7 +32,7 @@ export interface MaterialListResult {
export interface MaterialStat {
total: number;
total_size: number;
types: { count: number; type: string }[];
types: { count: number; type: number | string }[];
unused: number;
unused_size: number;
}
@@ -39,6 +40,8 @@ export interface MaterialStat {
export interface MaterialSyncResult {
finished: boolean;
inserted: number;
/** 本批从 OSS 列举到的对象数;为 0 且 finished 说明前缀或 Bucket 对不上 */
listed: number;
next_marker: string;
updated: number;
}
@@ -85,7 +88,11 @@ export async function getMaterialStat(): Promise<MaterialStat> {
return await requestClient.get<MaterialStat>('material/stat');
}
/** 单批拉取 OSS 对象finished 为假时带着 next_marker 再调一次 */
/**
* 单批拉取 OSS 对象finished 为假时带着 next_marker 再调一次
*
* 七牛要先查区域再列举再入库,默认 10s 必超时,这一口单独放到 10 分钟
*/
export async function syncMaterialFromOss(data: {
limit: number;
marker: string;
@@ -95,6 +102,7 @@ export async function syncMaterialFromOss(data: {
return await requestClient.post<MaterialSyncResult>(
'material/sync-from-oss',
data,
{ timeout: 600_000 },
);
}
@@ -183,25 +191,38 @@ export function formatSize(bytes?: null | number): string {
SIZE_UNITS.length - 1,
);
const scaled = value / 1024 ** index;
const text = index === 0 ? String(scaled) : scaled.toFixed(scaled >= 100 ? 0 : 1);
const text =
index === 0 ? String(scaled) : scaled.toFixed(scaled >= 100 ? 0 : 1);
return `${text} ${SIZE_UNITS[index]}`;
}
/** 与 FileModel::TYPE_* 对齐0 图片 / 1 视频 / 2 音频 / 3 表格 / 4 压缩包 / 5 文档 / 6 其他 */
const TYPE_LABELS: Record<string, string> = {
'0': '图片',
'1': '视频',
'2': '音频',
'3': '表格',
'4': '压缩包',
'5': '文档',
'6': '其他',
archive: '压缩包',
audio: '音频',
doc: '文档',
excel: '表格',
image: '图片',
other: '其他',
video: '视频',
};
/** 类型中文名;后端新增类型时原样展示,不做白名单拦截 */
export function materialTypeText(type?: string): string {
return TYPE_LABELS[type ?? ''] ?? (type || '未知');
/** 类型中文名;数字、字符串两种都认,避免拉取成功后卡片上写「未知」 */
export function materialTypeText(type?: number | string): string {
if (type === undefined || type === null || type === '') {
return '未知';
}
return TYPE_LABELS[String(type)] ?? String(type);
}
/** 只有图片能出缩略图与水印预览,其余类型走图标占位 */
/** 只有图片能出缩略图与水印预览;后端 type=0 就是图片 */
export function isImageMaterial(item: Pick<MaterialItem, 'type'>): boolean {
return item.type === 'image';
return Number(item.type) === 0 || item.type === 'image';
}

View File

@@ -1,5 +1,10 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
/**
* 从 OSS 分批拉取对象进素材库
*
* 进度靠 marker 续跑:超时或点停止都不要清 marker否则下一轮又从 Bucket 头开始扫。
*/
import { computed, ref, watch } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
@@ -22,10 +27,13 @@ defineOptions({
name: 'MaterialSyncDrawer',
});
/** 同一 marker 超时后最多再打两遍,避免网关抖动就整页重来 */
const MAX_BATCH_RETRY = 2;
const ossOptions = ref<{ label: string; value: number }[]>([]);
const ossConfigId = ref<number | undefined>();
const prefix = ref('');
const limit = ref(200);
const limit = ref(80);
const running = ref(false);
const stopped = ref(false);
@@ -37,6 +45,11 @@ const marker = ref('');
const processed = computed(() => inserted.value + updated.value);
/** 已经走过批次或留下 marker点开始应续跑而不是清零 */
const canContinue = computed(
() => !finished.value && (marker.value !== '' || batches.value > 0),
);
/**
* OSS 列举接口给不出对象总数,进度条只能表达「还在动」:
* 每批往前挪一格、封顶 95真正跑完才置 100真实进展看下面的数字。
@@ -52,6 +65,13 @@ const progressStatus = computed(() => {
return running.value ? ('active' as const) : ('normal' as const);
});
const startButtonText = computed(() => {
if (running.value) {
return '拉取中';
}
return canContinue.value ? '继续' : '开始';
});
function resetProgress() {
stopped.value = false;
finished.value = false;
@@ -61,6 +81,60 @@ function resetProgress() {
marker.value = '';
}
/** 换存储或换前缀后旧 marker 对不上,必须从头数;初次回填配置不要清 */
watch([ossConfigId, prefix], (_curr, prev) => {
if (running.value || prev[0] === undefined) {
return;
}
resetProgress();
});
function isTimeoutError(error: unknown): boolean {
const e = error as { code?: string; message?: string; status?: number };
if (e?.code === 'ECONNABORTED') {
return true;
}
const msg = String(e?.message ?? '').toLowerCase();
if (msg.includes('timeout') || msg.includes('超时')) {
return true;
}
return e?.status === 408 || e?.status === 504;
}
/**
* 打一页 OSS超时用同一个 marker 重试,成功才往前走
*/
async function syncOneBatch() {
if (!ossConfigId.value) {
throw new Error('请选择 OSS 配置');
}
const payload = {
limit: limit.value,
marker: marker.value,
oss_config_id: ossConfigId.value,
prefix: prefix.value.trim(),
};
let lastError: unknown;
for (let attempt = 0; attempt <= MAX_BATCH_RETRY; attempt += 1) {
try {
return await syncMaterialFromOss(payload);
} catch (error) {
lastError = error;
if (
!isTimeoutError(error) ||
attempt === MAX_BATCH_RETRY ||
stopped.value
) {
throw error;
}
message.warning(
`本批超时,正在用同一 marker 重试(${attempt + 1}/${MAX_BATCH_RETRY}`,
);
}
}
throw lastError;
}
const [Drawer, drawerApi] = useVbenDrawer({
footer: false,
onBeforeClose() {
@@ -75,7 +149,10 @@ const [Drawer, drawerApi] = useVbenDrawer({
if (!isOpen) {
return;
}
resetProgress();
// 超时后重开抽屉要保住 marker否则只能从头扫
if (!canContinue.value) {
resetProgress();
}
drawerApi.setState({ loading: true });
try {
const res: any = await getOssConfigList({ pageSize: 100 });
@@ -98,16 +175,18 @@ async function start() {
message.warning('请选择 OSS 配置');
return;
}
resetProgress();
// 跑完或从未开始才清零;超时/停止后点继续从当前 marker 续跑
if (finished.value || !canContinue.value) {
resetProgress();
} else {
stopped.value = false;
finished.value = false;
}
running.value = true;
let timedOut = false;
try {
while (!stopped.value) {
const res = await syncMaterialFromOss({
limit: limit.value,
marker: marker.value,
oss_config_id: ossConfigId.value,
prefix: prefix.value.trim(),
});
const res = await syncOneBatch();
inserted.value += res.inserted ?? 0;
updated.value += res.updated ?? 0;
batches.value += 1;
@@ -123,10 +202,24 @@ async function start() {
}
marker.value = nextMarker;
}
} catch (error) {
timedOut = isTimeoutError(error);
if (timedOut) {
message.error(
'本批仍超时,已保留当前 marker。可把单批数量调到 40 后再点「继续」',
);
}
} finally {
running.value = false;
}
if (finished.value) {
if (timedOut) {
return;
}
if (finished.value && inserted.value === 0 && updated.value === 0) {
message.warning(
'OSS 上没有列举到对象。本站历史七牛文件在 Bucket 根目录,请把「对象前缀」留空后重试;若仍为空,检查存储配置的 Bucket 与密钥。',
);
} else if (finished.value) {
message.success(`拉取完成,新增 ${inserted.value}、更新 ${updated.value}`);
popupData(drawerApi).gridApi?.reload();
} else if (stopped.value) {
@@ -134,6 +227,12 @@ async function start() {
}
}
/** 丢掉当前 marker从 Bucket 头重新扫 */
function restart() {
resetProgress();
void start();
}
function stop() {
stopped.value = true;
}
@@ -145,7 +244,7 @@ defineExpose(drawerApi);
<Drawer class="w-[560px]" title="从 OSS 拉取素材">
<Alert
class="mb-3"
message="按前缀分批列举 OSS 对象并入库,已存在的按路径更新。中途停止不会丢进度,下次从当前 marker 继续即可。"
message="按前缀分批列举 OSS 对象并入库,已存在的按路径更新。对象前缀留空表示扫整个 Bucket本站历史七牛文件在根目录不要填 uploads。超时或中途停止都会保留 marker,点「继续即可接着扫。"
show-icon
type="info"
/>
@@ -166,7 +265,7 @@ defineExpose(drawerApi);
<Input
v-model:value="prefix"
:disabled="running"
placeholder="如 uploads/,留空表示整个 Bucket"
placeholder="留空拉整个 Bucket不要填 uploads"
/>
</div>
<div>
@@ -175,12 +274,12 @@ defineExpose(drawerApi);
v-model:value="limit"
class="w-full"
:disabled="running"
:max="1000"
:max="200"
:min="10"
:step="50"
:step="10"
/>
<p class="text-muted-foreground mt-1 text-xs">
一批太大后端容易超时200 左右比较稳
一批太大容易超时默认 80仍超时再降到 40
</p>
</div>
</div>
@@ -208,7 +307,10 @@ defineExpose(drawerApi);
<div class="mt-4 flex gap-2">
<Button :loading="running" type="primary" @click="start">
{{ running ? '拉取中' : '开始' }}
{{ startButtonText }}
</Button>
<Button :disabled="running || !canContinue" @click="restart">
从头开始
</Button>
<Button danger :disabled="!running" @click="stop">停止</Button>
</div>

View File

@@ -77,7 +77,7 @@ const page = ref(1);
const pageSize = ref(24);
const keyword = ref('');
const type = ref<string | undefined>();
const type = ref<number | string | undefined>();
const unusedOnly = ref(false);
const selectedIds = ref<number[]>([]);
@@ -229,7 +229,9 @@ function openFolderModal(row?: Record<string, any>) {
folderModalApi.setData({
gridApi: folderReloader,
update: !!row,
values: row ? { id: row.id, name: row.name, pid: row.pid ?? 0 } : { pid: 0 },
values: row
? { id: row.id, name: row.name, pid: row.pid ?? 0 }
: { pid: 0 },
});
folderModalApi.open();
}
@@ -379,7 +381,9 @@ onMounted(() => {
allow-clear
class="w-[220px]"
placeholder="搜索素材名称或路径,回车确认"
@change="(e: any) => (e.target.value ? undefined : reloadFromFirstPage())"
@change="
(e: any) => (e.target.value ? undefined : reloadFromFirstPage())
"
@search="reloadFromFirstPage"
/>
<Select
@@ -421,7 +425,8 @@ onMounted(() => {
label: '扫描引用',
icon: 'lucide:scan-search',
popConfirm: {
title: '扫描会遍历所有业务表的图片字段,数据量大时较慢,继续吗?',
title:
'扫描会遍历所有业务表的图片字段,数据量大时较慢,继续吗?',
confirm: scanReferences,
},
},
@@ -464,8 +469,8 @@ onMounted(() => {
<div
v-for="item in items"
:key="item.id"
class="group relative overflow-hidden rounded-lg border transition-all"
:class="[
'group relative overflow-hidden rounded-lg border transition-all',
selectedIds.includes(item.id)
? 'border-primary shadow-md'
: 'hover:border-primary/40 hover:shadow-md',
@@ -474,17 +479,17 @@ onMounted(() => {
<Checkbox
class="absolute right-2 top-2 z-10"
:checked="selectedIds.includes(item.id)"
@change="
(e: any) => toggleSelect(item.id, !!e.target.checked)
"
@change="(e: any) => toggleSelect(item.id, !!e.target.checked)"
/>
<div class="bg-muted/40 flex h-[130px] items-center justify-center">
<div
class="bg-muted/40 flex h-[130px] items-center justify-center"
>
<Image
v-if="isImageMaterial(item)"
:height="130"
:preview="{ src: watermarkUrl(item.url, watermarkText) }"
:src="thumbUrl(item.url, 320)"
:width="'100%'"
width="100%"
/>
<Icon
v-else
@@ -497,7 +502,9 @@ onMounted(() => {
<div class="truncate text-sm" :title="item.name">
{{ item.name }}
</div>
<div class="text-muted-foreground mt-1 flex items-center gap-1 text-xs">
<div
class="text-muted-foreground mt-1 flex items-center gap-1 text-xs"
>
<span>{{ materialTypeText(item.type) }}</span>
<span>·</span>
<span>{{ formatSize(item.size) }}</span>

View File

@@ -64,7 +64,7 @@ export const gridOptions: VxeGridProps<RowType> = {
border: false,
toolbarConfig: {
// 是否显示搜索表单控制按钮
// @ts-ignore 正式环境时有完整的类型声明
// @ts-expect-error 正式环境时有完整的类型声明
search: true,
refresh: true, // 刷新
print: false, // 打印
@@ -72,7 +72,7 @@ export const gridOptions: VxeGridProps<RowType> = {
// custom: true, // 自定义列
zoom: true, // 最大化最小化
slots: {
buttons: 'toolbar-buttons',
buttons: 'toolbar-actions',
},
custom: {
// 自定义列-图标

View File

@@ -84,7 +84,7 @@ const onDeptSelect = (id?: number) => {
<FormModal />
<DeptTree @select="onDeptSelect" />
<Grid class="min-w-0 flex-1">
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{

View File

@@ -6,7 +6,7 @@ import { computed, onMounted, reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Input, Select, Spin, Switch, message } from 'ant-design-vue';
import { Input, message, Select, Spin, Switch } from 'ant-design-vue';
import { Icon } from '#/components/icon';
@@ -60,7 +60,7 @@ const DRIVER_ICONS: Record<string, string> = {
const loading = ref(false);
const list = ref<OssCard[]>([]);
const drivers = ref<DriverOption[]>([]);
const editingId = ref<number | null>(null);
const editingId = ref<null | number>(null);
const form = reactive({
driver: undefined as string | undefined,
@@ -80,13 +80,13 @@ const form = reactive({
/** 表单可按驱动显隐的字段 */
type OssFormField =
| 'access_key'
| 'secret_key'
| 'endpoint'
| 'region'
| 'bucket'
| 'domain'
| 'endpoint'
| 'path_prefix'
| 'remark';
| 'region'
| 'remark'
| 'secret_key';
interface DriverFieldSchema {
/** 该驱动需要展示的字段(按顺序) */
@@ -167,8 +167,9 @@ const DRIVER_FIELD_SCHEMA: Record<string, DriverFieldSchema> = {
required: ['access_key', 'secret_key', 'bucket', 'domain'],
placeholders: {
domain: '外链域名,含协议,如 https://cdn.example.com',
path_prefix: '历史文件在根目录,请留空',
},
hint: '七牛云:需配置外链访问域名(含协议)。',
hint: '七牛云:需配置外链访问域名(含协议)。本站历史文件在 Bucket 根目录,路径前缀请留空,不要填 uploads。',
},
huawei: {
fields: [
@@ -306,10 +307,7 @@ function showField(key: OssFormField) {
function isRequired(key: OssFormField) {
const req = fieldSchema.value?.required ?? [];
if (!req.includes(key)) return false;
if (
editingId.value &&
(key === 'access_key' || key === 'secret_key')
) {
if (editingId.value && (key === 'access_key' || key === 'secret_key')) {
return false;
}
return true;
@@ -331,10 +329,7 @@ const [FormModal, formModalApi] = useVbenModal({
if (schema) {
for (const key of schema.required) {
// 编辑时密钥留空 = 不改,跳过必填
if (
editingId.value &&
(key === 'access_key' || key === 'secret_key')
) {
if (editingId.value && (key === 'access_key' || key === 'secret_key')) {
continue;
}
const val = String((form as any)[key] ?? '').trim();
@@ -372,8 +367,8 @@ const [FormModal, formModalApi] = useVbenModal({
formModalApi.close();
await loadList();
emit('changed');
} catch (e) {
console.error(e);
} catch (error) {
console.error(error);
} finally {
formModalApi.unlock();
}
@@ -394,8 +389,8 @@ async function loadList() {
drivers.value = Array.isArray(driverRes)
? driverRes
: (driverRes?.items ?? driverRes?.options ?? []);
} catch (e) {
console.error(e);
} catch (error) {
console.error(error);
message.error('加载 OSS 配置失败');
} finally {
loading.value = false;
@@ -447,7 +442,7 @@ function openEdit(row: OssCard) {
* 若列表尚未加载则先拉取,找不到则提示
*/
async function openEditById(id: number) {
if (!list.value.length) {
if (list.value.length === 0) {
await loadList();
}
let row = list.value.find((item) => item.id === id);
@@ -472,8 +467,8 @@ async function handleDelete(row: OssCard) {
message.success('已删除');
await loadList();
emit('changed');
} catch (e) {
console.error(e);
} catch (error) {
console.error(error);
}
}
@@ -566,9 +561,8 @@ onMounted(() => {
<div class="key-card__secret">
<Icon icon="lucide:lock-keyhole" :size="14" />
<span>
AK {{ item.has_access_key ? '••••••••' : '未配置' }}
·
SK {{ item.has_secret_key ? '••••••••' : '未配置' }}
AK {{ item.has_access_key ? '••••••••' : '未配置' }} · SK
{{ item.has_secret_key ? '••••••••' : '未配置' }}
</span>
</div>
@@ -783,27 +777,27 @@ onMounted(() => {
.oss-keys__hero {
display: flex;
gap: 16px;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
padding: 18px 20px;
border-radius: 20px;
border: 1px solid hsl(var(--primary) / 0.18);
margin-bottom: 20px;
background:
linear-gradient(
135deg,
hsl(var(--primary) / 0.14),
hsl(var(--primary) / 0.03) 42%,
hsl(var(--primary) / 14%),
hsl(var(--primary) / 3%) 42%,
transparent 70%
),
hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--primary) / 18%);
border-radius: 20px;
}
.oss-keys__eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
align-items: center;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
@@ -825,27 +819,27 @@ onMounted(() => {
.oss-keys__add {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
gap: 8px;
align-items: center;
height: 40px;
padding: 0 16px;
border: none;
border-radius: 999px;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground, 0 0% 100%));
font-size: 13px;
font-weight: 600;
color: hsl(var(--primary-foreground, 0 0% 100%));
cursor: pointer;
box-shadow: 0 10px 24px hsl(var(--primary) / 0.28);
background: hsl(var(--primary));
border: none;
border-radius: 999px;
box-shadow: 0 10px 24px hsl(var(--primary) / 28%);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.oss-keys__add:hover {
box-shadow: 0 14px 28px hsl(var(--primary) / 35%);
transform: translateY(-1px);
box-shadow: 0 14px 28px hsl(var(--primary) / 0.35);
}
.oss-keys__grid {
@@ -856,16 +850,16 @@ onMounted(() => {
.key-card {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 220px;
padding: 20px;
border-radius: 22px;
border: 1px solid hsl(var(--border));
overflow: hidden;
background: hsl(var(--card, var(--background)));
box-shadow: 0 12px 32px hsl(var(--foreground) / 0.05);
border: 1px solid hsl(var(--border));
border-radius: 22px;
box-shadow: 0 12px 32px hsl(var(--foreground) / 5%);
transition:
transform 0.22s ease,
border-color 0.22s ease,
@@ -873,19 +867,18 @@ onMounted(() => {
}
.key-card:hover {
border-color: hsl(var(--primary) / 35%);
box-shadow: 0 18px 40px hsl(var(--primary) / 12%);
transform: translateY(-3px);
border-color: hsl(var(--primary) / 0.35);
box-shadow: 0 18px 40px hsl(var(--primary) / 0.12);
}
.key-card.is-default {
border-color: hsl(var(--primary) / 0.4);
background:
linear-gradient(
165deg,
hsl(var(--primary) / 0.1),
hsl(var(--card, var(--background))) 46%
);
background: linear-gradient(
165deg,
hsl(var(--primary) / 10%),
hsl(var(--card, var(--background))) 46%
);
border-color: hsl(var(--primary) / 40%);
}
.key-card.is-off {
@@ -898,22 +891,22 @@ onMounted(() => {
right: -20%;
width: 180px;
height: 180px;
border-radius: 999px;
pointer-events: none;
background: radial-gradient(
circle,
hsl(var(--primary) / 0.16),
hsl(var(--primary) / 16%),
transparent 68%
);
pointer-events: none;
border-radius: 999px;
}
.key-card__head {
position: relative;
z-index: 1;
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.key-card__brand {
@@ -924,19 +917,19 @@ onMounted(() => {
}
.key-card__avatar {
width: 52px;
height: 52px;
border-radius: 16px;
overflow: hidden;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
background: hsl(var(--muted) / 0.45);
width: 52px;
height: 52px;
overflow: hidden;
color: hsl(var(--primary));
background: hsl(var(--muted) / 45%);
border-radius: 16px;
box-shadow:
inset 0 0 0 1px hsl(var(--border)),
0 8px 18px hsl(var(--foreground) / 0.06);
flex-shrink: 0;
0 8px 18px hsl(var(--foreground) / 6%);
}
.key-card__title {
@@ -945,9 +938,9 @@ onMounted(() => {
.key-card__name-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.key-card__name-row h4 {
@@ -959,20 +952,20 @@ onMounted(() => {
.key-card__star {
display: inline-flex;
align-items: center;
gap: 4px;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
color: hsl(var(--warning, 38 92% 50%));
background: hsl(var(--warning, 38 92% 50%) / 0.14);
background: hsl(var(--warning, 38 92% 50%) / 14%);
border-radius: 999px;
}
.key-card__meta {
display: flex;
align-items: center;
gap: 6px;
align-items: center;
margin-top: 4px;
font-size: 12px;
color: hsl(var(--muted-foreground));
@@ -980,64 +973,64 @@ onMounted(() => {
.key-card__meta code {
padding: 1px 6px;
border-radius: 6px;
background: hsl(var(--muted) / 0.45);
font-size: 11px;
background: hsl(var(--muted) / 45%);
border-radius: 6px;
}
.key-card__ops {
display: flex;
gap: 6px;
flex-shrink: 0;
gap: 6px;
}
.key-card__op {
width: 32px;
height: 32px;
border-radius: 10px;
border: 1px solid hsl(var(--border));
background: hsl(var(--background) / 0.55);
color: hsl(var(--muted-foreground));
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: hsl(var(--muted-foreground));
cursor: pointer;
background: hsl(var(--background) / 55%);
border: 1px solid hsl(var(--border));
border-radius: 10px;
transition: all 0.2s ease;
}
.key-card__op:hover {
color: hsl(var(--primary));
border-color: hsl(var(--primary) / 0.4);
background: hsl(var(--primary) / 0.1);
background: hsl(var(--primary) / 10%);
border-color: hsl(var(--primary) / 40%);
}
.key-card__op.is-danger:hover {
color: hsl(0 72% 55%);
border-color: hsl(0 72% 55% / 0.4);
background: hsl(0 72% 55% / 0.1);
color: hsl(0deg 72% 55%);
background: hsl(0deg 72% 55% / 10%);
border-color: hsl(0deg 72% 55% / 40%);
}
.key-card__secret {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
align-items: center;
padding: 10px 12px;
border-radius: 12px;
background: hsl(var(--muted) / 0.35);
border: 1px dashed hsl(var(--border));
color: hsl(var(--muted-foreground));
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
color: hsl(var(--muted-foreground));
letter-spacing: 0.04em;
background: hsl(var(--muted) / 35%);
border: 1px dashed hsl(var(--border));
border-radius: 12px;
}
.key-card__remark {
position: relative;
z-index: 1;
margin: 0;
min-height: 40px;
margin: 0;
font-size: 13px;
line-height: 1.55;
color: hsl(var(--muted-foreground));
@@ -1054,49 +1047,49 @@ onMounted(() => {
.status-chip {
display: inline-flex;
align-items: center;
gap: 5px;
align-items: center;
height: 26px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
border-radius: 999px;
}
.status-chip.is-on {
color: hsl(142 55% 42%);
background: hsl(142 55% 42% / 0.14);
color: hsl(142deg 55% 42%);
background: hsl(142deg 55% 42% / 14%);
}
.status-chip.is-warn {
color: hsl(var(--warning, 38 92% 45%));
background: hsl(var(--warning, 38 92% 50%) / 0.14);
background: hsl(var(--warning, 38 92% 50%) / 14%);
}
.status-chip.is-off {
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.45);
background: hsl(var(--muted) / 45%);
}
.oss-keys__empty {
padding: 56px 20px;
border-radius: 22px;
border: 1px dashed hsl(var(--border));
text-align: center;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.15);
text-align: center;
background: hsl(var(--muted) / 15%);
border: 1px dashed hsl(var(--border));
border-radius: 22px;
}
.oss-keys__empty-icon {
width: 56px;
height: 56px;
margin: 0 auto 12px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin: 0 auto 12px;
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.12);
background: hsl(var(--primary) / 12%);
border-radius: 18px;
}
.oss-keys__empty h4 {
@@ -1114,8 +1107,8 @@ onMounted(() => {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
padding: 8px 4px 4px;
align-items: start;
padding: 8px 4px 4px;
}
.key-form__item {
@@ -1144,7 +1137,7 @@ onMounted(() => {
.key-form .req {
margin-left: 2px;
color: hsl(0 72% 55%);
color: hsl(0deg 72% 55%);
}
.key-form__hint {
@@ -1156,15 +1149,15 @@ onMounted(() => {
.key-form__switches {
display: flex;
flex-wrap: wrap;
gap: 24px;
margin-top: 4px;
flex-wrap: wrap;
}
.key-form__switches > div {
display: flex;
align-items: center;
gap: 10px;
align-items: center;
font-size: 13px;
}

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
/**
* 系统配置 · 套餐功能:按小程序应用开关。
* 数据仍写在 nl_wx_app.package_enabled两品牌共用库不能做成一条全局 kv。
*/
import { onMounted, ref } from 'vue';
import { Empty, message, Spin, Switch } from 'ant-design-vue';
import { Icon } from '#/components/icon';
import { wxAppApi } from '#/views/wx/app/api';
defineOptions({
name: 'PackageFeatureManage',
});
interface WxAppRow {
app_id?: string;
code?: string;
id: number;
name?: string;
package_enabled?: number;
status?: number;
}
const loading = ref(false);
const savingId = ref<null | number>(null);
const list = ref<WxAppRow[]>([]);
/** 拉应用列表,用来按品牌开关套餐 */
async function loadList() {
loading.value = true;
try {
const res = await wxAppApi.list({ page: 1, pageSize: 50 });
list.value = res?.items ?? [];
} finally {
loading.value = false;
}
}
/** 只改 package_enabled其它字段走应用配置页 */
async function togglePackage(row: WxAppRow, checked: boolean) {
savingId.value = row.id;
const next = checked ? 1 : 0;
const prev = row.package_enabled ?? 0;
row.package_enabled = next;
try {
await wxAppApi.update({
id: row.id,
package_enabled: next,
});
message.success(checked ? '已启用套餐' : '已关闭套餐');
} catch {
row.package_enabled = prev;
} finally {
savingId.value = null;
}
}
onMounted(loadList);
</script>
<template>
<div class="pkg-feature">
<div class="pkg-feature__tip">
<Icon icon="lucide:info" :size="16" />
<div>
开启后该品牌小程序底栏出现套餐首页分类下方展示热门套餐
搭配内容在商品中心 套餐搭配维护首页样式可在装修模板里调
两品牌共用一个库所以按应用分别开关不能做成一条全局配置
</div>
</div>
<Spin :spinning="loading">
<div v-if="list.length > 0" class="pkg-feature__list">
<div v-for="row in list" :key="row.id" class="pkg-feature__card">
<div class="pkg-feature__logo">
<Icon icon="lucide:layout-grid" :size="22" />
</div>
<div class="pkg-feature__body">
<div class="pkg-feature__name">
{{ row.name || row.code || '未命名应用' }}
</div>
<div class="pkg-feature__meta">
<code>{{ row.code || '-' }}</code>
<span v-if="row.app_id">{{ row.app_id }}</span>
</div>
</div>
<Switch
:checked="row.package_enabled === 1"
:loading="savingId === row.id"
checked-children=""
un-checked-children=""
@change="(checked: any) => togglePackage(row, !!checked)"
/>
</div>
</div>
<Empty
v-else-if="!loading"
class="pkg-feature__empty"
description="还没有应用记录,请先到「小程序 → 应用配置」新增或从 .env 初始化"
/>
</Spin>
</div>
</template>
<style scoped>
.pkg-feature {
max-width: 720px;
}
.pkg-feature__tip {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 12px 14px;
margin-bottom: 18px;
font-size: 13px;
line-height: 1.6;
color: hsl(var(--muted-foreground));
background: hsl(var(--primary) / 8%);
border: 1px solid hsl(var(--primary) / 15%);
border-radius: 14px;
}
.pkg-feature__tip :deep(svg) {
flex-shrink: 0;
margin-top: 3px;
color: hsl(var(--primary));
}
.pkg-feature__list {
display: flex;
flex-direction: column;
gap: 12px;
}
.pkg-feature__card {
display: flex;
gap: 14px;
align-items: center;
padding: 16px 18px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 16px;
}
.pkg-feature__logo {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
color: hsl(var(--primary));
background: hsl(var(--primary) / 10%);
border-radius: 12px;
}
.pkg-feature__body {
flex: 1;
min-width: 0;
}
.pkg-feature__name {
font-size: 15px;
font-weight: 600;
color: hsl(var(--foreground));
}
.pkg-feature__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.pkg-feature__meta code {
padding: 0 6px;
background: hsl(var(--muted) / 45%);
border-radius: 6px;
}
.pkg-feature__empty {
padding: 40px 0;
}
</style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* 系统配置页:左侧自写菜单;右侧按模块渲染 AI / OSS / 接口管理
* 系统配置页:左侧自写菜单;右侧按模块渲染 AI / OSS / 接口 / 套餐
* 左侧菜单与各模块 Tab 均持久化到 localStorage刷新后回到上次位置
*/
import { computed, ref, watch } from 'vue';
@@ -12,10 +12,11 @@ import { Icon } from '#/components/icon';
import AiManage from './components/AiManage.vue';
import ApiEndpointManage from './components/ApiEndpointManage.vue';
import OssManage from './components/OssManage.vue';
import PackageFeatureManage from './components/PackageFeatureManage.vue';
defineOptions({ name: 'SystemConfig' });
type ConfigMenuKey = 'ai' | 'oss' | 'api';
type ConfigMenuKey = 'ai' | 'api' | 'oss' | 'package';
interface ConfigMenuItem {
key: ConfigMenuKey;
@@ -47,6 +48,12 @@ const menus: ConfigMenuItem[] = [
desc: '操作日志开关',
icon: 'lucide:route',
},
{
key: 'package',
title: '套餐功能',
desc: '小程序套餐开关',
icon: 'lucide:layout-grid',
},
];
/**
@@ -118,6 +125,7 @@ function selectMenu(key: ConfigMenuKey) {
<AiManage v-if="activeKey === 'ai'" />
<OssManage v-else-if="activeKey === 'oss'" />
<ApiEndpointManage v-else-if="activeKey === 'api'" />
<PackageFeatureManage v-else-if="activeKey === 'package'" />
</div>
</main>
</div>
@@ -131,24 +139,24 @@ function selectMenu(key: ConfigMenuKey) {
background:
radial-gradient(
1200px 480px at 12% -10%,
hsl(var(--primary) / 0.12),
hsl(var(--primary) / 12%),
transparent 60%
),
radial-gradient(
900px 420px at 100% 0%,
hsl(var(--primary) / 0.06),
hsl(var(--primary) / 6%),
transparent 55%
),
hsl(var(--background));
}
.sys-config__aside {
width: 260px;
flex-shrink: 0;
border-right: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)) / 0.72);
backdrop-filter: blur(12px);
width: 260px;
padding: 20px 14px;
background: hsl(var(--card, var(--background)) / 72%);
border-right: 1px solid hsl(var(--border));
backdrop-filter: blur(12px);
}
.sys-config__brand {
@@ -159,19 +167,19 @@ function selectMenu(key: ConfigMenuKey) {
}
.sys-config__brand-icon {
width: 42px;
height: 42px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
color: hsl(var(--primary));
background: linear-gradient(
145deg,
hsl(var(--primary) / 0.18),
hsl(var(--primary) / 0.05)
hsl(var(--primary) / 18%),
hsl(var(--primary) / 5%)
);
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.18);
border-radius: 14px;
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 18%);
}
.sys-config__brand-title {
@@ -198,40 +206,40 @@ function selectMenu(key: ConfigMenuKey) {
align-items: center;
width: 100%;
padding: 12px;
color: hsl(var(--foreground));
text-align: left;
cursor: pointer;
background: transparent;
border: 1px solid transparent;
border-radius: 14px;
background: transparent;
cursor: pointer;
text-align: left;
transition: all 0.2s ease;
color: hsl(var(--foreground));
}
.sys-config__nav-item:hover {
background: hsl(var(--muted) / 0.35);
background: hsl(var(--muted) / 35%);
}
.sys-config__nav-item.is-active {
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary) / 0.35);
box-shadow: 0 8px 24px hsl(var(--primary) / 0.08);
background: hsl(var(--primary) / 10%);
border-color: hsl(var(--primary) / 35%);
box-shadow: 0 8px 24px hsl(var(--primary) / 8%);
}
.sys-config__nav-icon {
width: 36px;
height: 36px;
border-radius: 12px;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
background: hsl(var(--muted) / 0.45);
width: 36px;
height: 36px;
color: hsl(var(--muted-foreground));
flex-shrink: 0;
background: hsl(var(--muted) / 45%);
border-radius: 12px;
}
.sys-config__nav-item.is-active .sys-config__nav-icon {
background: hsl(var(--primary) / 0.16);
color: hsl(var(--primary));
background: hsl(var(--primary) / 16%);
}
.sys-config__nav-text {
@@ -247,11 +255,11 @@ function selectMenu(key: ConfigMenuKey) {
}
.sys-config__nav-desc {
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: hsl(var(--muted-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sys-config__main {
@@ -269,8 +277,8 @@ function selectMenu(key: ConfigMenuKey) {
.sys-config__header p {
margin: 6px 0 0;
color: hsl(var(--muted-foreground));
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.sys-config__body {

View File

@@ -30,8 +30,18 @@ export const gridOptions: VxeGridProps<RowType> = {
{ field: 'id', align: 'left', title: 'ID', width: 100 },
{ field: 'name', align: 'left', title: '部门名称', minWidth: 180 },
{ field: 'admin_count', title: '账号数', width: 100 },
{ field: 'status', title: '状态', width: 100, slots: { default: 'status' } },
{ field: 'color', title: '标签颜色', width: 120, slots: { default: 'color' } },
{
field: 'status',
title: '状态',
width: 100,
slots: { default: 'status' },
},
{
field: 'color',
title: '标签颜色',
width: 120,
slots: { default: 'color' },
},
{ field: 'desc', title: '备注', minWidth: 200 },
{ field: 'created_at', title: '创建时间', width: 180 },
{
@@ -65,14 +75,14 @@ export const gridOptions: VxeGridProps<RowType> = {
height: 'auto',
border: false,
toolbarConfig: {
// @ts-ignore 正式环境时有完整的类型声明
// @ts-expect-error 正式环境时有完整的类型声明
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: {
buttons: 'toolbar-buttons',
buttons: 'toolbar-actions',
},
custom: {
icon: 'vxe-icon-menu',

View File

@@ -94,7 +94,7 @@ const handleStatus = (row: any, checked: boolean) => {
<Page auto-content-height title="部门管理">
<FormModal />
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{

View File

@@ -78,7 +78,7 @@ export const gridOptions: VxeGridProps<RowType> = {
border: false,
toolbarConfig: {
// 是否显示搜索表单控制按钮
// @ts-ignore 正式环境时有完整的类型声明
// @ts-expect-error 正式环境时有完整的类型声明
search: true,
refresh: true, // 刷新
print: false, // 打印
@@ -86,7 +86,7 @@ export const gridOptions: VxeGridProps<RowType> = {
// custom: true, // 自定义列
zoom: true, // 最大化最小化
slots: {
buttons: 'toolbar-buttons',
buttons: 'toolbar-actions',
},
custom: {
// 自定义列-图标

View File

@@ -75,7 +75,7 @@ const collapseAll = () => {
<Page auto-content-height title="菜单管理">
<FormModal />
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{

View File

@@ -28,7 +28,12 @@ export const gridOptions: VxeGridProps<RowType> = {
{ field: 'id', align: 'left', title: 'ID', width: 100 },
{ field: 'name', align: 'left', title: '名称' },
{ field: 'value', title: '角色代码', slots: { default: 'color' } },
{ field: 'status', title: '状态', width: 110, slots: { default: 'status' } },
{
field: 'status',
title: '状态',
width: 110,
slots: { default: 'status' },
},
{ field: 'desc', title: '备注' },
{ field: 'created_at', title: '创建时间' },
{ type: 'html', title: '操作', width: 260, slots: { default: 'action' } },
@@ -51,7 +56,7 @@ export const gridOptions: VxeGridProps<RowType> = {
border: false,
toolbarConfig: {
// 是否显示搜索表单控制按钮
// @ts-ignore 正式环境时有完整的类型声明
// @ts-expect-error 正式环境时有完整的类型声明
search: true,
refresh: true, // 刷新
print: false, // 打印
@@ -59,7 +64,7 @@ export const gridOptions: VxeGridProps<RowType> = {
// custom: true, // 自定义列
zoom: true, // 最大化最小化
slots: {
buttons: 'toolbar-buttons',
buttons: 'toolbar-actions',
},
custom: {
// 自定义列-图标

View File

@@ -105,7 +105,7 @@ const deleteApi = (row: any) => {
<AuthMenu ref="authMenuRef" />
<AuthEndpoint ref="authEndpointRef" />
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{

View File

@@ -97,6 +97,33 @@ export const modalFormProps: VbenFormProps = {
fieldName: 'mch_private_key',
label: '商户私钥',
},
{
component: 'Select',
componentProps: {
options: [
{ label: '关闭', value: 0 },
{ label: '开启', value: 1 },
],
},
defaultValue: 0,
fieldName: 'package_enabled',
label: '启用套餐',
help: '开启后该品牌小程序底栏多「套餐」,首页分类下出现热门套餐',
},
{
component: 'VbenInput',
componentProps: { placeholder: '付款成功订阅消息模板 ID' },
fieldName: 'subscribe_pay_tpl',
label: '付款模板',
help: '关键词建议:订单编号 / 金额 / 付款成功。需与微信后台模板字段对齐',
},
{
component: 'VbenInput',
componentProps: { placeholder: '发货订阅消息模板 ID' },
fieldName: 'subscribe_ship_tpl',
label: '发货模板',
help: '关键词建议:订单编号 / 已发货 / 时间',
},
{
component: 'Select',
componentProps: { options: APP_STATUS_OPTIONS },

View File

@@ -18,6 +18,12 @@ export const gridOptions = createGridOptions({
{ field: 'app_id', title: '小程序 AppID', width: 200 },
{ field: 'mch_id', title: '商户号', width: 140 },
{ field: 'template_code', title: '默认模板', width: 140 },
{
field: 'package_enabled',
title: '套餐',
width: 90,
slots: { default: 'package' },
},
{
field: 'secrets',
title: '密钥配置',

View File

@@ -7,16 +7,16 @@ import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Alert, Button, Switch, Tag, message } from 'ant-design-vue';
import { Alert, Button, message, Switch, Tag } from 'ant-design-vue';
import { useCrudTable } from '#/components/crud';
import { TableAction } from '#/components/table-action';
import { initWxAppFromEnv, wxAppApi } from './api';
import WxAppFormModal from './components/modal.vue';
import { SECRET_FIELDS } from './config/constants';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
import WxAppFormModal from './components/modal.vue';
defineOptions({
name: 'WxApp',
@@ -39,6 +39,9 @@ const { FormModal, Grid, gridApi, removeRows, showModal, toggleStatus } =
mch_serial_no: row.mch_serial_no,
template_code: row.template_code,
notify_url: row.notify_url,
package_enabled: row.package_enabled ?? 0,
subscribe_pay_tpl: row.subscribe_pay_tpl ?? '',
subscribe_ship_tpl: row.subscribe_ship_tpl ?? '',
status: row.status,
remark: row.remark,
// 把 *_set 一起带进弹窗,用于展示密钥配置状态
@@ -66,7 +69,10 @@ async function handleInitFromEnv() {
gridApi.reload();
if (res?.id && !res?.app_secret_set) {
const detail = await wxAppApi.detail(res.id);
showModal(detail ?? { id: res.id, code: res.code, app_id: res.app_id }, true);
showModal(
detail ?? { id: res.id, code: res.code, app_id: res.app_id },
true,
);
}
}
@@ -83,15 +89,17 @@ onMounted(refreshEmptyHint);
<Alert v-if="emptyHint" class="mb-3" show-icon type="warning">
<template #message>
还没有任何应用记录登录/code2session 会报AppSecret 未配置可点
.env 初始化建壳或手动新增应用填写
code brm/ AppID / AppSecret
.env 初始化建壳或手动新增应用填写 code brm/ AppID /
AppSecret
</template>
<template #action>
<Button type="primary" @click="handleInitFromEnv"> .env 初始化</Button>
<Button type="primary" @click="handleInitFromEnv">
.env 初始化
</Button>
</template>
</Alert>
<Grid>
<template #toolbar-buttons>
<template #toolbar-actions>
<TableAction
:actions="[
{
@@ -129,6 +137,11 @@ onMounted(refreshEmptyHint);
</Tag>
</div>
</template>
<template #package="{ row }">
<Tag :color="row.package_enabled === 1 ? 'green' : 'default'">
{{ row.package_enabled === 1 ? '已开启' : '关闭' }}
</Tag>
</template>
<template #status="{ row }">
<Switch
:checked="row.status === 0"

View File

@@ -25,25 +25,63 @@ export async function exportTemplates(ids: number[]) {
/** 导入模板 JSON */
export async function importTemplates(data: {
payload: any;
overwrite?: boolean;
payload: any;
}) {
return requestClient.post<any>('wx-template/import', data);
}
/** 用内置 20 套预设初始化模板库 */
/** 用内置 5 套预设初始化模板库4 店面轻奢 + 刊页,顺带灌卡片方案) */
export async function initPresets(overwrite = false) {
return requestClient.post<any>('wx-template/init-presets', { overwrite });
}
export const wxCardSchemeApi = createCrudApi('wx-card-scheme');
/** 卡片方案列表(含 layers */
export async function getCardSchemeList() {
return requestClient.get<any>('wx-card-scheme/list', {
params: { page: 1, pageSize: 100 },
});
}
/** 保存当前卡片方案图层 */
export async function updateCardScheme(id: number, data: Record<string, any>) {
return requestClient.post<any>('wx-card-scheme/update', { id, ...data });
}
/** 自由设计后另存为新方案 */
export async function saveCardSchemeAs(data: {
code?: string;
layers: any[];
name: string;
}) {
return requestClient.post<any>('wx-card-scheme/save-as', data);
}
/** 灌 30 套内置卡片预设 */
export async function initCardSchemes(overwrite = false) {
return requestClient.post<any>('wx-card-scheme/init-presets', { overwrite });
}
/**
* 模板 schema(令牌与布局的可选项)。
* 后端 WxTemplateController::schema 直接返回 TOKEN_SCHEMA + LAYOUT_SCHEMA 常量,
* 前端编辑表单按这个 schema 渲染下拉选项。
* 模板 schema:工作室样式画廊按 studio 字段渲染,不再写死变体
*/
export async function getTemplateSchema(): Promise<{
tokens: Record<string, string[]>;
card_families?: Record<string, string>;
labels: Record<string, Record<string, string>>;
layout: Record<string, Record<string, string[]>>;
page_presets?: Record<string, any[]>;
pages: Record<string, string[]>;
studio: Array<{
key: string;
modules: Array<{
type: string;
variants: Array<{ label: string; value: string }>;
}>;
}>;
tokens: Record<string, string[]>;
variants: Record<string, string[]>;
}> {
return requestClient.get('wx-template/schema');
}

View File

@@ -1,28 +1,35 @@
<script lang="ts" setup>
/**
* 装修模板卡片预览:用 swatch + layout_hint 画迷你首页,方便肉眼区分风格
* preview 图优先;没有图时用色板拼出轮播/分类/商品骨架
* 模板列表上的迷你手机:按 layout_hint 画接近小程序首页的缩略预览
* preview 图就用图;否则用家具样图,不再用「轮播 / 类目」色块
*/
import { computed } from 'vue';
import {
PREVIEW_CATS,
PREVIEW_GOODS,
PREVIEW_PHOTOS,
previewFallback,
} from '../preview-assets';
defineOptions({ name: 'WxTemplateCardPreview' });
const props = withDefaults(
defineProps<{
layoutHint?: {
category?: string;
hero?: string;
product?: string;
};
preview?: string;
styleTag?: string;
swatch?: {
primary?: string;
accent?: string;
bg?: string;
border?: string;
primary?: string;
surface?: string;
text?: string;
border?: string;
};
layoutHint?: {
hero?: string;
category?: string;
product?: string;
};
}>(),
{
@@ -42,101 +49,94 @@ const colors = computed(() => ({
border: props.swatch?.border || '#E7DFD3',
}));
const heroLabel = computed(() => {
const map: Record<string, string> = {
fullscreen: '全屏头图',
split: '左右分栏',
carousel: '轮播',
banner: '横幅',
};
return map[props.layoutHint?.hero || ''] || '头图';
});
const hero = computed(() => props.layoutHint?.hero || 'carousel');
const category = computed(() => props.layoutHint?.category || 'grid');
const product = computed(() => props.layoutHint?.product || 'grid');
const productCols = computed(() => (product.value === 'list' ? 1 : 2));
const cats = computed(() =>
category.value === 'sidebar'
? PREVIEW_CATS.slice(0, 3)
: PREVIEW_CATS.slice(0, 3),
);
const goods = computed(() => PREVIEW_GOODS.slice(0, productCols.value * 2));
const categoryLabel = computed(() => {
const map: Record<string, string> = {
scroll: '横向分类',
sidebar: '侧栏分类',
grid: '宫格分类',
card: '卡片分类',
};
return map[props.layoutHint?.category || ''] || '分类';
});
const productLabel = computed(() => {
const map: Record<string, string> = {
magazine: '杂志流',
waterfall: '瀑布流',
grid: '双列商品',
list: '列表商品',
};
return map[props.layoutHint?.product || ''] || '商品';
});
const productCols = computed(() => {
const p = props.layoutHint?.product || 'grid';
return p === 'list' ? 1 : 2;
});
function onImgError(event: Event, index = 1) {
const img = event.target as HTMLImageElement;
if (img.dataset.fallback === '1') return;
img.dataset.fallback = '1';
img.src = previewFallback(index);
}
</script>
<template>
<div class="preview-root">
<img v-if="preview" :alt="styleTag || 'preview'" class="preview-img" :src="preview" />
<div v-else class="mock" :style="{ background: colors.bg, color: colors.text }">
<div class="mock__phone">
<div
class="mock__hero"
:style="{
background: `linear-gradient(135deg, ${colors.primary}, ${colors.accent})`,
}"
>
<span>{{ heroLabel }}</span>
</div>
<div class="mock__cats">
<span
v-for="n in 3"
:key="n"
class="mock__chip"
:style="{
background: colors.surface,
borderColor: colors.border,
color: colors.text,
}"
>
{{ n === 1 ? categoryLabel : '类目' }}
</span>
</div>
<div
class="mock__products"
:style="{ gridTemplateColumns: `repeat(${productCols}, 1fr)` }"
>
<div
v-for="n in productCols * 2"
:key="n"
class="mock__card"
:style="{
background: colors.surface,
borderColor: colors.border,
}"
>
<div
class="mock__thumb"
:style="{ background: n % 2 ? colors.primary : colors.accent }"
></div>
<div class="mock__line" :style="{ background: colors.border }"></div>
<div
class="mock__price"
:style="{ background: colors.primary, opacity: 0.85 }"
></div>
</div>
</div>
<div class="mock__hint">{{ productLabel }}</div>
<img
v-if="preview"
:alt="styleTag || 'preview'"
class="preview-img"
:src="preview"
/>
<div
v-else
class="phone"
:style="{ background: colors.bg, color: colors.text }"
>
<div class="phone__nav" :style="{ background: colors.surface }">
<b>家具优选</b>
<i class="phone__capsule" :style="{ borderColor: colors.border }"></i>
</div>
<div class="mock__swatches">
<i :style="{ background: colors.primary }" title="主色"></i>
<i :style="{ background: colors.accent }" title="辅色"></i>
<i :style="{ background: colors.bg }" title="背景"></i>
<i :style="{ background: colors.surface }" title="表面"></i>
<i :style="{ background: colors.text }" title="文字"></i>
<div class="phone__hero" :class="`hero--${hero}`">
<img :src="PREVIEW_PHOTOS[0]" alt="" @error="onImgError($event, 1)" />
<span
v-if="hero === 'split'"
class="phone__split"
:style="{ color: colors.text, background: colors.surface }"
>
ATELIER
</span>
</div>
<div class="phone__cats" :class="`cats--${category}`">
<div
v-for="(item, index) in cats"
:key="item.name"
class="phone__cat"
:style="{ background: colors.surface, borderColor: colors.border }"
>
<img
:src="item.cover"
alt=""
@error="onImgError($event, index + 1)"
/>
<em>{{ item.name }}</em>
</div>
</div>
<div
v-if="product !== 'hidden'"
class="phone__goods"
:style="{ gridTemplateColumns: `repeat(${productCols}, 1fr)` }"
>
<div
v-for="item in goods"
:key="item.title"
class="phone__good"
:style="{ background: colors.surface, borderColor: colors.border }"
>
<img :src="item.cover" alt="" @error="onImgError($event, 1)" />
<em>{{ item.title }}</em>
</div>
</div>
<div
class="phone__tab"
:style="{
background: colors.surface,
borderColor: colors.border,
color: colors.primary,
}"
>
<span>首页</span>
<span :style="{ color: colors.text, opacity: 0.45 }">图册</span>
<span :style="{ color: colors.text, opacity: 0.45 }">清单</span>
<span :style="{ color: colors.text, opacity: 0.45 }">我的</span>
</div>
</div>
</div>
@@ -144,116 +144,175 @@ const productCols = computed(() => {
<style scoped>
.preview-root {
height: 100%;
width: 100%;
height: 100%;
}
.preview-img {
height: 100%;
width: 100%;
height: 100%;
object-fit: cover;
}
.mock {
.phone {
display: flex;
flex-direction: column;
height: 100%;
flex-direction: column;
gap: 8px;
padding: 10px 10px 8px;
overflow: hidden;
}
.mock__phone {
.phone__nav {
position: relative;
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 6px;
}
.mock__hero {
display: flex;
height: 42px;
flex-shrink: 0;
align-items: flex-end;
border-radius: 8px;
padding: 6px 8px;
color: #fff;
font-size: 10px;
align-items: center;
justify-content: center;
height: 18px;
}
.phone__nav b {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.04em;
text-shadow: 0 1px 2px rgb(0 0 0 / 35%);
}
.mock__cats {
display: flex;
gap: 4px;
overflow: hidden;
}
.mock__chip {
flex-shrink: 0;
.phone__capsule {
position: absolute;
top: 4px;
right: 6px;
width: 28px;
height: 10px;
border: 1px solid;
border-radius: 999px;
padding: 2px 7px;
font-size: 9px;
line-height: 1.4;
white-space: nowrap;
}
.mock__products {
display: grid;
min-height: 0;
flex: 1;
gap: 5px;
}
.mock__card {
display: flex;
flex-direction: column;
gap: 3px;
.phone__hero {
position: relative;
flex-shrink: 0;
height: 52px;
margin: 4px 8px 0;
overflow: hidden;
border: 1px solid;
border-radius: 6px;
padding: 4px;
}
.mock__thumb {
height: 22px;
border-radius: 4px;
opacity: 0.55;
.phone__hero img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mock__line,
.mock__price {
height: 3px;
border-radius: 2px;
.hero--fullscreen,
.hero--caption {
height: 64px;
margin: 0;
border-radius: 0;
}
.mock__line {
width: 70%;
.hero--banner {
height: 28px;
}
.mock__price {
width: 40%;
.hero--split img {
width: calc(100% - 12px);
}
.mock__hint {
font-size: 9px;
letter-spacing: 0.06em;
opacity: 0.55;
}
.mock__swatches {
.phone__split {
position: absolute;
top: 0;
right: 0;
bottom: 0;
display: flex;
flex-shrink: 0;
gap: 4px;
align-items: center;
justify-content: center;
width: 12px;
font-size: 6px;
letter-spacing: 1px;
writing-mode: vertical-rl;
}
.mock__swatches i {
.phone__cats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
padding: 5px 8px 0;
}
.cats--scroll,
.cats--pills {
display: flex;
}
.cats--scroll .phone__cat,
.cats--pills .phone__cat {
min-width: 42px;
}
.cats--card,
.cats--mosaic {
grid-template-columns: 1fr 1fr;
}
.cats--sidebar {
grid-template-columns: 22px 1fr 1fr;
}
.phone__cat {
overflow: hidden;
border: 1px solid;
border-radius: 4px;
}
.phone__cat img {
display: block;
height: 12px;
width: 12px;
border: 1px solid hsl(var(--border) / 0.8);
border-radius: 999px;
width: 100%;
height: 22px;
object-fit: cover;
}
.phone__cat em {
display: block;
padding: 2px 0;
font-size: 8px;
font-style: normal;
text-align: center;
}
.phone__goods {
display: grid;
flex: 1;
gap: 4px;
min-height: 0;
padding: 5px 8px 0;
}
.phone__good {
overflow: hidden;
border: 1px solid;
border-radius: 4px;
}
.phone__good img {
display: block;
width: 100%;
height: 28px;
object-fit: cover;
}
.phone__good em {
display: block;
padding: 2px 3px;
font-size: 8px;
font-style: normal;
}
.phone__tab {
display: grid;
flex-shrink: 0;
grid-template-columns: repeat(4, 1fr);
height: 16px;
margin-top: auto;
font-size: 7px;
line-height: 16px;
text-align: center;
border-top: 1px solid;
}
</style>

View File

@@ -37,7 +37,9 @@ const [Modal, modalApi] = useVbenModal({
modalApi.lock();
try {
const res = await doImport({ payload, overwrite: overwrite.value });
message.success(`导入完成:新增 ${res.inserted},更新 ${res.updated},跳过 ${res.skipped}`);
message.success(
`导入完成:新增 ${res.inserted},更新 ${res.updated},跳过 ${res.skipped}`,
);
popupData(modalApi).gridApi?.reload();
modalApi.close();
} finally {
@@ -56,14 +58,14 @@ const [Modal, modalApi] = useVbenModal({
<Modal class="w-[640px]" title="导入模板">
<Alert
class="mb-3"
message="粘贴之前导出的模板 JSON。导入是 strict 模式:令牌键与布局枚举值不在白名单内的会被拒绝。"
message="粘贴导出 JSON。strict 白名单:非法 tokens/layout 会被拒绝。日常请走「装修工作室」改色板与区块,勿按 template.code 特判页面。"
show-icon
type="info"
/>
<Input.Textarea
v-model:value="jsonText"
:rows="10"
placeholder='{"version":1,"templates":[...]}'
placeholder="{&quot;version&quot;:1,&quot;templates&quot;:[...]}"
/>
<div class="mt-3 flex items-center gap-2">
<Switch v-model:checked="overwrite" />

View File

@@ -29,16 +29,17 @@ import {
setTemplateStatus,
wxTemplateApi,
} from './api';
import WxTemplateCardPreview from './components/card-preview.vue';
import WxTemplateImportModal from './components/import-modal.vue';
import WxTemplateFormModal from './components/modal.vue';
import WxTemplateCardPreview from './components/card-preview.vue';
import WxTemplateStudio from './studio/index.vue';
defineOptions({ name: 'WxTemplate' });
const loading = ref(false);
const items = ref<Record<string, any>[]>([]);
const keyword = ref('');
const statusFilter = ref<'all' | '0' | '1'>('all');
const statusFilter = ref<'0' | '1' | 'all'>('all');
const isEmpty = ref(false);
const statusOptions = [
@@ -50,7 +51,10 @@ const statusOptions = [
const filtered = computed(() => {
const q = keyword.value.trim().toLowerCase();
return items.value.filter((row) => {
if (statusFilter.value !== 'all' && String(row.status) !== statusFilter.value) {
if (
statusFilter.value !== 'all' &&
String(row.status) !== statusFilter.value
) {
return false;
}
if (!q) {
@@ -76,6 +80,9 @@ const [FormModal, formModalApi] = useVbenModal({
const [ImportModal, importModalApi] = useVbenModal({
connectedComponent: WxTemplateImportModal,
});
const [StudioModal, studioModalApi] = useVbenModal({
connectedComponent: WxTemplateStudio,
});
/** 导入弹窗仍约定 gridApi.reload */
const gridApiBridge = { reload: load };
@@ -165,6 +172,12 @@ function openImport() {
importModalApi.open();
}
/** 打开装修工作室:色板 / 区块 / 画布 */
function openStudio(row: Record<string, any>) {
studioModalApi.setData({ id: row.id, onSaved: load });
studioModalApi.open();
}
onMounted(load);
</script>
@@ -172,20 +185,23 @@ onMounted(load);
<Page
auto-content-height
content-class="flex min-h-0 flex-col"
description="卡片单选设为品牌默认主题;经销商专属模板请到「经销商管理」单独绑定。"
description="只保留 4 套轻奢(素胚 / 珠贝白 / 胡桃木 / 香槟金)。点「装修」进工作室改色板、拖区块、编辑 canvas 卡片;经销商专属请到「经销商管理」绑定。"
title="小程序装修模板"
>
<FormModal />
<ImportModal />
<StudioModal />
<Alert v-if="isEmpty" class="mb-3" show-icon type="warning">
<template #message>
模板库为空建表不会灌数据请初始化内置 20 套预设
模板库为空请初始化内置 4 轻奢预设
<code>config/wx_templates.php</code>
</template>
<template #action>
<Button type="primary" @click="handleInitPresets">立即初始化预设</Button>
<Button type="primary" @click="handleInitPresets">
立即初始化预设
</Button>
</template>
</Alert>
@@ -214,7 +230,8 @@ onMounted(load);
label: '初始化预设',
icon: 'lucide:sparkles',
popConfirm: {
title: '用内置 20 套覆盖初始化?同 code 会被覆盖。',
title:
'用内置 4 套轻奢覆盖初始化?仅同 code 会被覆盖,并灌 15 套卡片方案。',
confirm: handleInitPresets,
},
},
@@ -261,7 +278,11 @@ onMounted(load);
:style-tag="row.style_tag"
:swatch="row.swatch"
/>
<Tag v-if="row.is_default === 1" class="template-card__badge" color="green">
<Tag
v-if="row.is_default === 1"
class="template-card__badge"
color="green"
>
当前默认
</Tag>
</div>
@@ -281,6 +302,9 @@ onMounted(load);
{{ row.status === 0 ? '启用' : '停用' }}
</Tag>
<div class="template-card__actions">
<Button size="small" type="link" @click="openStudio(row)">
装修
</Button>
<Button size="small" type="link" @click="showModal(row, true)">
编辑
</Button>
@@ -320,11 +344,11 @@ onMounted(load);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 14px;
background: hsl(var(--card, var(--background)));
text-align: left;
cursor: pointer;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 14px;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
@@ -332,13 +356,13 @@ onMounted(load);
}
.template-card:hover {
border-color: hsl(var(--primary) / 0.45);
box-shadow: 0 10px 28px hsl(var(--foreground) / 0.08);
border-color: hsl(var(--primary) / 45%);
box-shadow: 0 10px 28px hsl(var(--foreground) / 8%);
}
.template-card.is-selected {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 1px hsl(var(--primary) / 0.35);
box-shadow: 0 0 0 1px hsl(var(--primary) / 35%);
}
.template-card.is-disabled {
@@ -351,13 +375,13 @@ onMounted(load);
left: 12px;
z-index: 2;
display: flex;
height: 18px;
width: 18px;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: hsl(var(--card, var(--background)) / 90%);
border: 1.5px solid hsl(var(--border));
border-radius: 999px;
background: hsl(var(--card, var(--background)) / 0.9);
}
.template-card.is-selected .template-card__radio {
@@ -365,10 +389,10 @@ onMounted(load);
}
.template-card__dot {
height: 8px;
width: 8px;
border-radius: 999px;
height: 8px;
background: transparent;
border-radius: 999px;
}
.template-card.is-selected .template-card__dot {
@@ -379,15 +403,15 @@ onMounted(load);
position: relative;
height: 168px;
overflow: hidden;
background: hsl(var(--muted) / 0.35);
background: hsl(var(--muted) / 35%);
}
.template-card__badge {
position: absolute;
top: 10px;
right: 10px;
margin: 0;
z-index: 2;
margin: 0;
}
.template-card__body {
@@ -399,19 +423,19 @@ onMounted(load);
}
.template-card__name {
color: hsl(var(--foreground));
font-size: 15px;
font-weight: 600;
color: hsl(var(--foreground));
}
.template-card__meta {
color: hsl(var(--muted-foreground));
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.template-card__layout {
color: hsl(var(--muted-foreground));
font-size: 11px;
color: hsl(var(--muted-foreground));
letter-spacing: 0.02em;
opacity: 0.85;
}

View File

@@ -0,0 +1,53 @@
/**
* 工作室 / 模板列表共用的预览样图
* 七牛实拍优先SVG 兜底,避免 Unsplash 在国内打不开后只剩色块
*/
function svgUri(markup: string) {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`;
}
const ROOM = (inner: string, wall = '#EFE6D8', floor = '#D8C9B4') =>
svgUri(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid slice"><rect width="800" height="600" fill="${wall}"/><rect y="420" width="800" height="180" fill="${floor}"/><rect y="418" width="800" height="4" fill="#C9B79A"/>${inner}</svg>`,
);
export const PREVIEW_PHOTOS = [
'http://qiniu.boerman.top/b_a2d78f027b3d26c0b0621655726c0c54.jpg',
ROOM(
`<rect x="120" y="250" width="560" height="36" rx="8" fill="#8C6A3F"/><rect x="90" y="286" width="620" height="150" rx="18" fill="#B08D57"/><rect x="110" y="300" width="180" height="70" rx="10" fill="#D8C4A0"/><rect x="510" y="300" width="180" height="70" rx="10" fill="#D8C4A0"/><rect x="330" y="180" width="140" height="70" rx="6" fill="#C4B39A"/>`,
'#F3EBE0',
'#CDB89A',
),
ROOM(
`<rect x="180" y="300" width="440" height="18" fill="#6B5340"/><rect x="200" y="318" width="400" height="90" rx="4" fill="#E8D9C4"/><rect x="160" y="250" width="28" height="170" fill="#5C4636"/><rect x="612" y="250" width="28" height="170" fill="#5C4636"/><rect x="340" y="210" width="120" height="90" fill="#F7F1E8"/>`,
'#EDE4D6',
'#C6B396',
),
ROOM(
`<rect x="160" y="220" width="480" height="200" rx="8" fill="#C9B396"/><rect x="180" y="200" width="440" height="40" rx="8" fill="#A89070"/><rect x="200" y="248" width="400" height="24" fill="#F4EEE4"/><rect x="80" y="300" width="60" height="120" rx="6" fill="#8C6A3F"/>`,
'#E8DFD2',
'#D2C0A6',
),
];
export const PREVIEW_CATS = [
{ name: '客厅', cover: PREVIEW_PHOTOS[0] },
{ name: '餐厅', cover: PREVIEW_PHOTOS[1] },
{ name: '卧室', cover: PREVIEW_PHOTOS[2] },
{ name: '书房', cover: PREVIEW_PHOTOS[3] },
];
export const PREVIEW_GOODS = [
{ title: 'BR-1208', cover: PREVIEW_PHOTOS[0] },
{ title: 'WL-3301', cover: PREVIEW_PHOTOS[1] },
{ title: 'BR-4412', cover: PREVIEW_PHOTOS[2] },
{ title: 'WL-2098', cover: PREVIEW_PHOTOS[3] },
];
/** 图片失败时换成对应 SVG保证小手机始终有家具画面 */
export function previewFallback(index = 1) {
return (
PREVIEW_PHOTOS[Math.min(Math.max(index, 1), PREVIEW_PHOTOS.length - 1)] ||
PREVIEW_PHOTOS[1]
);
}

View File

@@ -0,0 +1,587 @@
<script lang="ts" setup>
import type { CardLayer, ThemeTokens } from './types';
/**
* 卡片画布渲染:后台预览与编辑共用
* 图层坐标按 340×420 设计稿,外层用 scale 缩放到预览宽度
*/
import { computed } from 'vue';
import { resolveColor } from './helpers';
defineOptions({ name: 'WxStudioCanvasCard' });
const props = withDefaults(
defineProps<{
bind?: { cover?: string; gallery?: string; name?: string; price?: string };
caption?: string;
/** 工作室编辑框带描边;真机预览里关掉,避免套一层灰框 */
chrome?: boolean;
editable?: boolean;
layers: CardLayer[];
selectedId?: string;
tokens: ThemeTokens;
width?: number;
}>(),
{
bind: () => ({
cover: '',
name: '型号 SAMPLE',
price: '¥ 1,280',
gallery: '',
}),
selectedId: '',
editable: false,
width: 170,
chrome: true,
caption: '',
},
);
const emit = defineEmits<{
move: [id: string, x: number, y: number];
select: [id: string];
}>();
const CANVAS_W = 340;
const CANVAS_H = 420;
const scale = computed(() => props.width / CANVAS_W);
const sorted = computed(() =>
[...(props.layers || [])]
.filter((l) => l.visible !== false)
.sort((a, b) => (a.z || 0) - (b.z || 0)),
);
function layerStyle(layer: CardLayer) {
return {
left: `${layer.x}px`,
top: `${layer.y}px`,
width: `${layer.w}px`,
height: `${layer.h}px`,
transform: `rotate(${layer.rotate || 0}deg)`,
zIndex: layer.z || 1,
};
}
function fillOf(layer: CardLayer) {
return resolveColor(layer.fill, props.tokens);
}
/** 配饰色只吃主题 token缺 tone 时用主色,绝不回落到纯黑 */
function toneOf(layer: CardLayer) {
return resolveColor(layer.tone || 'primary', props.tokens);
}
function textOf(layer: CardLayer) {
if (layer.bind === 'name') return props.bind?.name || '型号';
// 列表预览会传入空报价,不要回落到 ¥ —
if (layer.bind === 'price') return props.bind?.price || '';
if (props.caption && (!layer.bind || layer.bind === 'custom'))
return props.caption;
if (layer.bind === 'custom') return layer.text || '';
return layer.text || '';
}
const FACE_ZH: Record<string, string> = {
sans: 'PingFang SC, Microsoft YaHei, sans-serif',
serif: 'Songti SC, Noto Serif SC, serif',
kai: 'Kaiti SC, STKaiti, serif',
script: 'Kaiti SC, STKaiti, serif',
};
const FACE_EN: Record<string, string> = {
sans: '-apple-system, Helvetica, sans-serif',
serif: 'Georgia, Times New Roman, serif',
kai: 'Georgia, serif',
script: 'Snell Roundhand, Segoe Script, cursive',
};
function faceOf(layer: CardLayer, kind: 'en' | 'zh') {
const key = kind === 'zh' ? layer.font_zh || 'sans' : layer.font_en || 'sans';
return kind === 'zh'
? FACE_ZH[key] || FACE_ZH.sans
: FACE_EN[key] || FACE_EN.sans;
}
function textRuns(layer: CardLayer) {
const raw = textOf(layer);
const parts: { kind: 'en' | 'zh'; text: string }[] = [];
raw.split(/([\u3400-\u9FFF]+)/).forEach((chunk) => {
if (!chunk) return;
parts.push({
text: chunk,
kind: /[\u3400-\u9FFF]/.test(chunk) ? 'zh' : 'en',
});
});
return parts.length > 0 ? parts : [{ text: raw, kind: 'en' as const }];
}
function photoOf(layer: CardLayer) {
if (layer.bind === 'gallery')
return props.bind?.gallery || props.bind?.cover || '';
if (layer.src) return layer.src;
return props.bind?.cover || '';
}
function fontSize(layer: CardLayer) {
const map: Record<string, string> = {
title: '18px',
body: '14px',
caption: '12px',
price: '16px',
};
return map[layer.font || 'body'] || '14px';
}
function textColor(layer: CardLayer) {
if (layer.font === 'price')
return props.tokens.color?.price || props.tokens.color?.primary;
return props.tokens.color?.text || '#1C1917';
}
let drag: null | {
id: string;
ox: number;
oy: number;
sx: number;
sy: number;
} = null;
function onPointerDown(event: PointerEvent, layer: CardLayer) {
if (!props.editable || layer.locked) {
emit('select', layer.id);
return;
}
emit('select', layer.id);
const target = event.currentTarget as HTMLElement;
target.setPointerCapture(event.pointerId);
drag = {
id: layer.id,
ox: event.clientX,
oy: event.clientY,
sx: layer.x,
sy: layer.y,
};
}
function onPointerMove(event: PointerEvent) {
if (!drag) return;
const dx = (event.clientX - drag.ox) / scale.value;
const dy = (event.clientY - drag.oy) / scale.value;
emit('move', drag.id, Math.round(drag.sx + dx), Math.round(drag.sy + dy));
}
function onPointerUp() {
drag = null;
}
</script>
<template>
<div
class="canvas-shell"
:class="{ 'is-bare': !chrome }"
:style="{
width: `${width}px`,
height: `${CANVAS_H * scale}px`,
background: tokens.color?.bg || '#FAF7F2',
}"
>
<div
class="canvas-stage"
:style="{ transform: `scale(${scale})`, transformOrigin: 'top left' }"
>
<div
v-for="layer in sorted"
:key="layer.id"
class="layer"
:class="{
'is-selected': editable && selectedId === layer.id,
'is-locked': layer.locked,
}"
:style="layerStyle(layer)"
@pointerdown="onPointerDown($event, layer)"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
>
<div
v-if="layer.type === 'shape'"
class="fill"
:style="{ background: fillOf(layer) }"
></div>
<img
v-else-if="layer.type === 'photo' && photoOf(layer)"
class="photo"
:src="photoOf(layer)"
alt=""
/>
<div
v-else-if="layer.type === 'photo'"
class="photo-ph"
:style="{ background: tokens.color?.primary || '#B08D57' }"
></div>
<div
v-else-if="layer.type === 'text'"
class="txt"
:style="{ color: textColor(layer), fontSize: fontSize(layer) }"
>
<span
v-for="(run, ri) in textRuns(layer)"
:key="ri"
:style="{ fontFamily: faceOf(layer, run.kind) }"
>{{ run.text }}</span>
</div>
<div
v-else-if="layer.type === 'deco'"
class="deco"
:class="`deco--${layer.asset || 'gold-corner'}`"
:style="{ color: toneOf(layer) }"
></div>
</div>
</div>
</div>
</template>
<style scoped>
.canvas-shell {
position: relative;
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.canvas-shell.is-bare {
background: transparent !important;
border: none;
border-radius: 0;
}
.canvas-stage {
position: relative;
width: 340px;
height: 420px;
}
.layer {
position: absolute;
box-sizing: border-box;
touch-action: none;
}
.layer.is-selected {
outline: 1.5px dashed hsl(var(--primary));
}
.fill,
.photo,
.photo-ph,
.txt,
.deco {
width: 100%;
height: 100%;
}
.photo {
object-fit: cover;
}
.photo-ph {
opacity: 0.35;
}
.txt {
overflow: hidden;
text-overflow: ellipsis;
font-family: 'Songti SC', 'Noto Serif SC', serif;
line-height: 1.3;
white-space: nowrap;
}
.deco {
position: relative;
pointer-events: none;
}
.deco--paper-edge {
background: repeating-linear-gradient(
-8deg,
color-mix(in srgb, currentcolor 10%, transparent) 0 2px,
transparent 2px 7px
);
border: 1px dashed currentcolor;
box-shadow:
inset 0 0 0 7px color-mix(in srgb, currentcolor 16%, transparent),
inset 0 0 18px color-mix(in srgb, currentcolor 12%, transparent);
opacity: 0.85;
}
.deco--pearl-line,
.deco--folio-line {
background: linear-gradient(
135deg,
color-mix(in srgb, currentcolor 8%, transparent),
transparent 40%
);
box-shadow:
inset 0 0 0 1px currentcolor,
inset 0 0 0 5px color-mix(in srgb, currentcolor 14%, white),
inset 0 0 0 6px currentcolor;
opacity: 0.8;
}
.deco--double-frame {
box-shadow:
inset 0 0 0 2px currentcolor,
inset 0 0 0 7px color-mix(in srgb, currentcolor 10%, transparent),
inset 0 0 0 9px currentcolor,
0 0 0 1px color-mix(in srgb, currentcolor 40%, transparent);
opacity: 0.82;
}
.deco--walnut-frame {
background: repeating-linear-gradient(
90deg,
color-mix(in srgb, currentcolor 18%, transparent) 0 3px,
transparent 3px 8px
);
border: 12px solid currentcolor;
box-shadow:
inset 0 0 0 3px color-mix(in srgb, currentcolor 45%, white),
inset 0 0 16px color-mix(in srgb, currentcolor 25%, transparent),
0 2px 0 color-mix(in srgb, currentcolor 40%, transparent);
opacity: 0.9;
}
.deco--gold-foil {
background: linear-gradient(
125deg,
color-mix(in srgb, currentcolor 8%, transparent) 0%,
color-mix(in srgb, currentcolor 28%, white) 36%,
color-mix(in srgb, currentcolor 12%, transparent) 62%,
currentcolor 100%
);
border: 1px solid currentcolor;
box-shadow:
inset 0 0 0 5px color-mix(in srgb, currentcolor 22%, transparent),
0 1px 0 color-mix(in srgb, currentcolor 40%, white);
opacity: 0.55;
}
.deco--washi-1 {
background: repeating-linear-gradient(
90deg,
currentcolor 0 3px,
color-mix(in srgb, currentcolor 35%, transparent) 3px 6px
);
border-radius: 1px;
opacity: 0.42;
}
.deco--botanical {
background:
radial-gradient(circle at 30% 30%, currentcolor 0 18%, transparent 19%),
radial-gradient(circle at 70% 60%, currentcolor 0 14%, transparent 15%);
border-radius: 70% 30% 60% 40%;
opacity: 0.38;
}
.deco--museum-mat {
box-shadow:
inset 0 0 0 18px color-mix(in srgb, currentcolor 22%, transparent),
inset 0 0 0 19px currentcolor;
opacity: 0.55;
}
.deco--wax-seal {
background:
radial-gradient(
circle at 32% 28%,
color-mix(in srgb, currentcolor 55%, white),
currentcolor 58%
),
repeating-conic-gradient(
from 20deg,
currentcolor 0 12deg,
color-mix(in srgb, currentcolor 70%, white) 12deg 24deg
);
border-radius: 999px;
box-shadow:
inset 0 0 0 4px color-mix(in srgb, currentcolor 45%, white),
0 3px 6px color-mix(in srgb, currentcolor 28%, transparent);
opacity: 0.82;
}
.deco--ribbon {
background: currentcolor;
opacity: 0.55;
filter: drop-shadow(
0 2px 2px color-mix(in srgb, currentcolor 25%, transparent)
);
clip-path: polygon(0 42%, 50% 0, 100% 42%, 82% 100%, 50% 68%, 18% 100%);
}
.deco--newsprint {
background: repeating-linear-gradient(
-12deg,
currentcolor 0 1px,
transparent 1px 7px
);
opacity: 0.14;
}
.deco--gold-corner,
.deco--corner-bracket {
background:
linear-gradient(currentcolor, currentcolor) top left / 18px 2px no-repeat,
linear-gradient(currentcolor, currentcolor) top left / 2px 18px no-repeat,
linear-gradient(currentcolor, currentcolor) top right / 18px 2px no-repeat,
linear-gradient(currentcolor, currentcolor) top right / 2px 18px no-repeat,
linear-gradient(currentcolor, currentcolor) bottom left / 18px 2px no-repeat,
linear-gradient(currentcolor, currentcolor) bottom left / 2px 18px no-repeat,
linear-gradient(currentcolor, currentcolor) bottom right / 18px 2px
no-repeat,
linear-gradient(currentcolor, currentcolor) bottom right / 2px 18px
no-repeat;
}
.deco--corner-bracket {
background:
linear-gradient(currentcolor, currentcolor) top left / 22px 3px no-repeat,
linear-gradient(currentcolor, currentcolor) top left / 3px 22px no-repeat,
linear-gradient(currentcolor, currentcolor) bottom right / 22px 3px
no-repeat,
linear-gradient(currentcolor, currentcolor) bottom right / 3px 22px
no-repeat;
}
.deco--handwriting {
border-bottom: 1px solid currentcolor;
opacity: 0.55;
transform: rotate(-2deg);
}
.deco--arch-mat {
border: 2px solid currentcolor;
border-radius: 140px 140px 8px 8px;
box-shadow: inset 0 0 0 8px color-mix(in srgb, currentcolor 12%, transparent);
opacity: 0.7;
}
.deco--inset-shadow {
box-shadow:
inset 0 2px 10px color-mix(in srgb, currentcolor 28%, transparent),
inset 0 0 0 1px currentcolor;
opacity: 0.55;
}
.deco--torn-edge {
box-shadow: inset 0 0 0 1px currentcolor;
opacity: 0.35;
clip-path: polygon(2% 3%, 98% 0, 100% 97%, 0 100%, 3% 52%);
}
.deco--film-sprocket {
background:
repeating-linear-gradient(currentcolor 0 6px, transparent 6px 18px) left /
8px 100% no-repeat,
repeating-linear-gradient(currentcolor 0 6px, transparent 6px 18px) right /
8px 100% no-repeat;
box-shadow: inset 0 0 0 1px currentcolor;
opacity: 0.45;
}
.deco--emboss-plate {
border: 1px solid currentcolor;
border-radius: 2px;
box-shadow:
inset 0 1px 0 color-mix(in srgb, currentcolor 35%, white),
0 2px 6px color-mix(in srgb, currentcolor 18%, transparent);
opacity: 0.7;
}
.deco--circle-crop {
border: 3px solid currentcolor;
border-radius: 999px;
box-shadow: inset 0 0 0 6px color-mix(in srgb, currentcolor 12%, transparent);
opacity: 0.75;
}
.deco--vignette {
background: radial-gradient(
ellipse at center,
transparent 42%,
currentcolor 100%
);
opacity: 0.28;
}
.deco--folio-number {
letter-spacing: 1px;
border: 1px solid currentcolor;
opacity: 0.7;
}
.deco--stamp-ring {
border: 2px dashed currentcolor;
border-radius: 999px;
box-shadow: inset 0 0 0 6px color-mix(in srgb, currentcolor 20%, transparent);
opacity: 0.55;
}
.deco--lace-corner {
background:
radial-gradient(circle at 0 0, currentcolor 0 12px, transparent 13px),
radial-gradient(circle at 100% 0, currentcolor 0 12px, transparent 13px),
radial-gradient(circle at 0 100%, currentcolor 0 12px, transparent 13px),
radial-gradient(circle at 100% 100%, currentcolor 0 12px, transparent 13px),
radial-gradient(
circle at 8px 8px,
transparent 7px,
currentcolor 8px 9px,
transparent 10px
),
radial-gradient(
circle at calc(100% - 8px) 8px,
transparent 7px,
currentcolor 8px 9px,
transparent 10px
);
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentcolor 35%, transparent);
opacity: 0.55;
}
.deco--thin-rule {
height: 1px;
background: currentcolor;
opacity: 0.7;
}
.deco--shadow-deck {
background: color-mix(in srgb, currentcolor 18%, transparent);
box-shadow: 4px 6px 0 color-mix(in srgb, currentcolor 22%, transparent);
}
.deco--cap-rail {
background: linear-gradient(
currentcolor,
color-mix(in srgb, currentcolor 55%, white)
);
box-shadow: 0 2px 0 color-mix(in srgb, currentcolor 30%, transparent);
opacity: 0.8;
}
.deco--gutter-fold {
background: linear-gradient(90deg, transparent, currentcolor, transparent);
opacity: 0.28;
}
.deco--pearl-bead {
border: 6px dotted currentcolor;
border-radius: 18px;
opacity: 0.45;
}
.deco--vert-caption {
border-left: 1px solid currentcolor;
opacity: 0.55;
}
</style>

View File

@@ -0,0 +1,229 @@
import type {
CardLayer,
SectionItem,
StudioModule,
ThemeChrome,
ThemeLayout,
ThemeTokens,
} from './types';
import { PAGE_VARIANT_FALLBACK, VARIANT_LABELS } from './types';
/** 把 token 名或色值解析成可上屏的颜色 */
export function resolveColor(
fill: string | undefined,
tokens: ThemeTokens,
): string {
if (!fill) return tokens.color?.surface || '#FFFFFF';
if (fill.startsWith('#') || fill.startsWith('rgb')) return fill;
return tokens.color?.[fill] || tokens.color?.surface || '#FFFFFF';
}
/** 合并底栏 chrome保留动画和独立两色避免只抄 tabbar/navbar 把配置丢掉 */
export function normalizeChrome(chrome?: ThemeChrome): ThemeChrome {
const next: ThemeChrome = {
tabbar: chrome?.tabbar || 'plain',
navbar: chrome?.navbar || 'solid',
tabbar_anim: chrome?.tabbar_anim || 'slide',
};
if (chrome?.tabbar_color) next.tabbar_color = chrome.tabbar_color;
if (chrome?.tabbar_color_on) next.tabbar_color_on = chrome.tabbar_color_on;
return next;
}
/** 缺 pages 时按旧枚举补一套默认区块,避免工作室空列表 */
export function ensurePages(layout: ThemeLayout): ThemeLayout {
const next: ThemeLayout = {
...layout,
pages: { ...layout.pages },
};
const home = layout.home || {};
const product = layout.product || {};
const list = layout.list || {};
const mine = layout.mine || {};
const scheme = layout.card_scheme || 'champagne-foil';
const goodsList = normalizeGoodsList(home.product || 'waterfall');
const defaults: Record<string, SectionItem[]> = {
home: [
sec('search', 'search', 'bar'),
sec('hero', 'hero', home.hero || 'carousel', { height: 480 }),
sec('category', 'category', home.category || 'grid', { cols: 3 }),
sec('package', 'package', home.package || 'card'),
sec('product', 'product', goodsList, { card_scheme: scheme }, false),
],
package: [sec('search', 'search', 'bar'), sec('list', 'list', 'card')],
catalog: [
sec('filter', 'filter', 'chip'),
sec('list', 'list', goodsList, { card_scheme: scheme }),
],
search: [
sec('search', 'search', 'bar'),
sec('list', 'list', goodsList, { card_scheme: scheme }),
],
product: [
sec('gallery', 'gallery', product.gallery || 'swiper'),
sec('info', 'info', 'plain'),
sec('spec', 'spec', 'plain'),
sec('price', 'price', product.price || 'inline'),
sec('action', 'action', product.action || 'fixed'),
],
cart: [sec('list', 'list', list.style || 'card')],
mine: [
sec('header', 'header', mine.header || 'plain'),
sec('menu', 'menu', mine.menu || 'list'),
],
};
for (const [key, sections] of Object.entries(defaults)) {
if (next.pages?.[key]?.sections?.length) {
const mapped = next.pages![key]!.sections.map((item) =>
remapLegacySection(key, item),
);
const have = new Set(mapped.map((item) => item.type));
for (const extra of sections) {
if (have.has(extra.type)) continue;
// 首页套餐插在分类后、商品流前,避免旧模板补段时掉到最底下
if (key === 'home' && extra.type === 'package') {
const catIdx = mapped.findIndex((item) => item.type === 'category');
mapped.splice(catIdx !== -1 ? catIdx + 1 : mapped.length, 0, extra);
} else {
mapped.push(extra);
}
have.add(extra.type);
}
next.pages![key] = { ...next.pages![key], sections: mapped };
} else {
next.pages![key] = { sections };
}
}
if (!next.card_scheme) next.card_scheme = scheme;
if (!next.page_presets) next.page_presets = {};
next.chrome = normalizeChrome(next.chrome);
return next;
}
/** 一键套用整页预设:写 sections + 页级 skin/density/frame + 卡片方案 */
export function applyPagePreset(
layout: ThemeLayout,
page: string,
preset: {
card_scheme?: string;
code: string;
density?: string;
frame?: string;
sections?: SectionItem[];
skin?: string;
},
): ThemeLayout {
const pages = { ...layout.pages };
const current = pages[page] || { sections: [] };
pages[page] = {
...current,
sections: (preset.sections || []).map((item, index) => ({
id: item.id || `${item.type}-${index}`,
type: item.type,
variant: item.variant,
visible: item.visible !== false,
props: item.props || {},
})),
skin: preset.skin || current.skin || 'shop',
density: preset.density || 'regular',
frame: preset.frame || 'none',
card_scheme:
preset.card_scheme || current.card_scheme || layout.card_scheme,
};
return syncEnums({
...layout,
pages,
page_presets: { ...layout.page_presets, [page]: preset.code },
});
}
/** 旧杂志卡带报价,列表页收成瀑布 */
function normalizeGoodsList(variant: string): string {
if (
variant === 'magazine' ||
variant === 'card' ||
variant === 'table' ||
variant === 'timeline'
) {
return 'waterfall';
}
return variant || 'waterfall';
}
function remapLegacySection(page: string, item: SectionItem): SectionItem {
const allowed = PAGE_VARIANT_FALLBACK[page]?.[item.type];
if (!allowed) return item;
if (allowed.includes(item.variant)) return item;
return { ...item, variant: allowed[0] || item.variant };
}
function sec(
id: string,
type: string,
variant: string,
props: Record<string, number | string> = {},
visible = true,
): SectionItem {
return { id, type, variant, visible, props };
}
/** 区块改完后回写旧枚举,未升级的小程序仍能认 hero/category */
export function syncEnums(layout: ThemeLayout): ThemeLayout {
const pick = (page: string, type: string, fallback: string) => {
const found = layout.pages?.[page]?.sections?.find((s) => s.type === type);
return found?.variant || fallback;
};
return {
...layout,
home: {
...layout.home,
hero: pick('home', 'hero', 'carousel'),
category: pick('home', 'category', 'grid'),
package: pick('home', 'package', 'card'),
product: pick('home', 'product', 'waterfall'),
},
product: {
...layout.product,
gallery: pick('product', 'gallery', 'swiper'),
price: pick('product', 'price', 'inline'),
action: pick('product', 'action', 'fixed'),
},
list: {
...layout.list,
style: pick('cart', 'list', 'card'),
},
mine: {
...layout.mine,
header: pick('mine', 'header', 'plain'),
menu: pick('mine', 'menu', 'list'),
},
package: {
...layout.package,
list: pick('package', 'list', 'card'),
},
};
}
export function newLayerId(prefix: string): string {
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
}
export function cloneLayers(layers: CardLayer[]): CardLayer[] {
return JSON.parse(JSON.stringify(layers || [])) as CardLayer[];
}
/** 当前页某模块的可选样式:优先用接口 studio否则本地兜底 */
export function moduleVariants(
page: string,
type: string,
studio: StudioModule[] | undefined,
): { label: string; value: string }[] {
const fromApi = studio?.find((m) => m.type === type)?.variants;
if (fromApi?.length) return fromApi;
const allowed =
PAGE_VARIANT_FALLBACK[page]?.[type] ||
Object.keys(VARIANT_LABELS[type] || {});
const labels = VARIANT_LABELS[type] || {};
return allowed.map((value) => ({ value, label: labels[value] || value }));
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,304 @@
/** 装修工作室共用类型:页面区块 + 卡片图层 */
export interface ThemeTokens {
color?: Record<string, string>;
font?: Record<string, string>;
radius?: Record<string, string>;
shadow?: Record<string, string>;
space?: Record<string, string>;
motion?: Record<string, string>;
}
export interface SectionItem {
id: string;
type: string;
variant: string;
visible: boolean;
props: Record<string, number | string>;
}
export interface PageSections {
sections: SectionItem[];
}
export interface PageMeta extends PageSections {
skin?: string;
density?: string;
frame?: string;
card_scheme?: string;
card_caption?: string;
}
export interface ThemeChrome {
tabbar?: string;
tabbar_anim?: string;
tabbar_color?: string;
tabbar_color_on?: string;
navbar?: string;
}
export interface ThemeLayout {
home?: Record<string, string>;
product?: Record<string, string>;
list?: Record<string, string>;
mine?: Record<string, string>;
package?: Record<string, string>;
effect?: Record<string, string>;
chrome?: ThemeChrome;
card_scheme?: string;
page_presets?: Record<string, string>;
pages?: Record<string, PageMeta>;
}
export interface CardLayer {
id: string;
type: 'deco' | 'photo' | 'shape' | 'text';
x: number;
y: number;
w: number;
h: number;
rotate?: number;
z?: number;
visible?: boolean;
locked?: boolean;
bind?: string;
font?: string;
asset?: string;
fill?: string;
tone?: string;
text?: string;
src?: string;
font_zh?: string;
font_en?: string;
}
export interface CardScheme {
id?: number;
code: string;
name: string;
layers: CardLayer[];
is_preset?: number;
preview?: string;
family?: string;
}
export interface PagePresetItem {
code: string;
name: string;
family?: string;
skin?: string;
card_scheme?: string;
density?: string;
frame?: string;
hero?: string;
gallery?: string;
list?: string;
sections?: SectionItem[];
}
export interface StudioVariant {
value: string;
label: string;
}
export interface StudioModule {
type: string;
variants: StudioVariant[];
}
export interface StudioPage {
key: string;
modules: StudioModule[];
}
export const PAGE_KEYS = [
{ key: 'home', label: '首页' },
{ key: 'package', label: '套餐' },
{ key: 'catalog', label: '列表' },
{ key: 'search', label: '搜索' },
{ key: 'product', label: '详情' },
{ key: 'cart', label: '清单' },
{ key: 'mine', label: '我的' },
] as const;
export const SECTION_TITLES: Record<string, string> = {
search: '搜索',
hero: '头图 / 轮播',
category: '分类',
package: '热门套餐',
product: '商品流(可选)',
filter: '顶栏筛选',
list: '列表',
gallery: '相册',
info: '标题简介',
spec: '规格报价',
price: '报价位置',
action: '加入清单',
header: '头部',
menu: '菜单',
};
/** 兜底中文名:接口 schema 没回来时仍能画画廊 */
export const VARIANT_LABELS: Record<string, Record<string, string>> = {
hero: {
banner: '横幅',
carousel: '轮播',
split: '分栏',
fullscreen: '全屏',
stack: '堆叠',
coverflow: '封面流',
fade: '叠化',
peek: '露边',
cube: '立方',
caption: '刊名条',
},
category: {
grid: '宫格',
scroll: '横滑',
card: '卡片',
sidebar: '侧栏',
pills: '胶囊',
mosaic: '马赛克',
featured: '首图',
tile: '瓷砖',
contents: '刊页目录',
},
package: {
card: '卡片',
scroll: '横滑',
featured: '首图',
magazine: '刊名条',
list: '单列',
},
product: { waterfall: '瀑布', list: '单列', grid: '双列' },
list: {
card: '卡片',
table: '表格',
timeline: '时间线',
waterfall: '瀑布',
grid: '双列',
list: '单列',
masonry: '砌石',
featured: '首图',
magazine: '刊名条',
shelf: '货架',
compact: '紧凑',
airy: '疏朗',
mosaic: '马赛克',
duo: '对开',
ticket: '票根',
stacked: '层叠',
},
gallery: {
swiper: '轮播',
stack: '叠图',
fullbleed: '全出血',
peek: '露边',
fade: '叠化',
coverflow: '封面流',
mosaic: '拼贴',
filmstrip: '胶卷',
},
action: { fixed: '钉在底部', inline: '跟正文走', split: '左右拆开' },
header: {
gradient: '渐变',
image: '头图',
plain: '素底',
split: '分栏',
editorial: '编辑',
},
menu: {
grid: '宫格',
list: '列表',
card: '卡片',
tile: '瓷砖',
compact: '紧凑',
},
filter: { chip: '分类条', bar: '仅搜索', sidebar: '侧栏', hidden: '隐藏' },
search: { bar: '搜索条', overlay: '压在封面', pill: '胶囊', float: '悬浮' },
info: { plain: '常规', editorial: '编辑', split: '分栏', overlay: '压字' },
spec: { plain: '规格表', table: '表格', chips: '芯片', cards: '卡片' },
price: { inline: '跟规格走', card: '独立报价卡', sticky: '吸顶报价' },
};
export const PAGE_VARIANT_FALLBACK: Record<string, Record<string, string[]>> = {
home: {
product: ['waterfall', 'grid', 'list'],
package: ['card', 'scroll', 'featured', 'magazine'],
},
package: {
search: ['bar', 'pill', 'overlay', 'float'],
list: ['card', 'featured', 'magazine', 'list'],
},
catalog: {
list: [
'waterfall',
'grid',
'list',
'masonry',
'featured',
'shelf',
'compact',
'airy',
'mosaic',
'duo',
],
},
search: {
list: [
'waterfall',
'grid',
'list',
'masonry',
'featured',
'shelf',
'compact',
'airy',
'mosaic',
'duo',
],
},
cart: { list: ['card', 'table', 'timeline', 'compact', 'ticket', 'stacked'] },
};
export const SCHEME_FAMILY_LABEL: Record<string, string> = {
lux: '轻奢',
editorial: '欧美',
atelier: '手作',
};
export const CHROME_TABBAR_OPTIONS = [
{ value: 'plain', label: '常规' },
{ value: 'line', label: '滑块' },
{ value: 'pill', label: '胶囊' },
{ value: 'dot', label: '圆点' },
] as const;
export const CHROME_TABBAR_ANIM_OPTIONS = [
{ value: 'none', label: '无' },
{ value: 'fade', label: '淡入' },
{ value: 'slide', label: '滑动' },
{ value: 'spring', label: '回弹' },
] as const;
export const CHROME_NAVBAR_OPTIONS = [
{ value: 'solid', label: '实底' },
{ value: 'line', label: '细线' },
] as const;
export const LAYER_FACE_OPTIONS = [
{ value: 'sans', label: '黑体' },
{ value: 'serif', label: '宋体' },
{ value: 'kai', label: '楷体' },
{ value: 'script', label: '手写' },
] as const;
export const PALETTE_FIELDS = [
{ key: 'primary', label: '主色' },
{ key: 'accent', label: '辅色' },
{ key: 'bg', label: '背景' },
{ key: 'surface', label: '表面' },
{ key: 'text', label: '正文' },
{ key: 'border', label: '边线' },
{ key: 'price', label: '价格(仅详情)' },
] as const;

View File

@@ -4,8 +4,17 @@ export default defineConfig(async () => {
return {
application: {},
vite: {
// exceljs 默认入口带 Node fs浏览器走打包好的 UMD
optimizeDeps: {
include: ['exceljs'],
},
resolve: {
alias: {
exceljs: 'exceljs/dist/exceljs.min.js',
},
},
server: {
port: 14002,
port: 14_002,
proxy: {
'/api': {
changeOrigin: true,

325
pnpm-lock.yaml generated
View File

@@ -758,6 +758,9 @@ importers:
dayjs:
specifier: 'catalog:'
version: 1.11.21
exceljs:
specifier: ^4.4.0
version: 4.4.0
jszip:
specifier: ^3.10.1
version: 3.10.1
@@ -3753,6 +3756,12 @@ packages:
resolution: {integrity: sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==}
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
'@fast-csv/format@4.3.5':
resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==}
'@fast-csv/parse@4.3.6':
resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==}
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
@@ -5629,6 +5638,9 @@ packages:
'@types/node@12.20.55':
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
'@types/node@14.18.63':
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
'@types/node@24.13.3':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
@@ -6483,10 +6495,22 @@ packages:
dmg-builder: 26.15.3
electron-builder-squirrel-windows: 26.15.3
archiver-utils@2.1.0:
resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==}
engines: {node: '>= 6'}
archiver-utils@3.0.4:
resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==}
engines: {node: '>= 10'}
archiver-utils@5.0.2:
resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==}
engines: {node: '>= 14'}
archiver@5.3.2:
resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==}
engines: {node: '>= 10'}
archiver@7.0.1:
resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==}
engines: {node: '>= 14'}
@@ -6671,9 +6695,16 @@ packages:
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
engines: {node: '>=4'}
big-integer@1.6.52:
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
engines: {node: '>=0.6'}
bignumber.js@9.3.1:
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
binary@0.3.0:
resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
@@ -6683,6 +6714,12 @@ packages:
birpc@4.1.0:
resolution: {integrity: sha512-O8L9vALWGqdEe0cG4HJckauw3WeJETlJnDRPUYpgwB7wrU43b/5NGMdVjdVcRo+4ROgd3ih2wha1glDe4HRVgw==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
bluebird@3.4.7:
resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
bluebird@3.7.2:
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
@@ -6716,6 +6753,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
buffer-crc32@1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
@@ -6730,9 +6770,20 @@ packages:
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
engines: {node: '>=4.0'}
buffer-indexof-polyfill@1.0.2:
resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==}
engines: {node: '>=0.10'}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
buffers@0.1.1:
resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==}
engines: {node: '>=0.2.0'}
builder-util-runtime@9.7.0:
resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==}
engines: {node: '>=12.0.0'}
@@ -6813,6 +6864,9 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chainsaw@0.1.0:
resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==}
chalk-template@1.1.2:
resolution: {integrity: sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA==}
engines: {node: '>=14.16'}
@@ -6997,6 +7051,10 @@ packages:
compatx@0.2.0:
resolution: {integrity: sha512-6gLRNt4ygsi5NyMVhceOCFv14CIdDFN7fQjX1U4+47qVE/+kjPoXMK65KWK+dWxmFzMTuKazoQ9sch6pM0p5oA==}
compress-commons@4.1.2:
resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==}
engines: {node: '>= 10'}
compress-commons@6.0.2:
resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==}
engines: {node: '>= 14'}
@@ -7096,6 +7154,10 @@ packages:
engines: {node: '>=0.8'}
hasBin: true
crc32-stream@4.0.3:
resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==}
engines: {node: '>= 10'}
crc32-stream@6.0.0:
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
engines: {node: '>= 14'}
@@ -7878,6 +7940,10 @@ packages:
evtd@0.2.4:
resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==}
exceljs@4.4.0:
resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==}
engines: {node: '>=8.3.0'}
execa@10.0.1:
resolution: {integrity: sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw==}
engines: {node: '>=22'}
@@ -7903,6 +7969,10 @@ packages:
extendable-error@0.1.7:
resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==}
fast-csv@4.3.6:
resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==}
engines: {node: '>=10.0.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -8036,6 +8106,9 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
fs-extra@10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
@@ -8073,6 +8146,11 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fstream@1.0.12:
resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==}
engines: {node: '>=0.6'}
deprecated: This package is no longer supported.
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -9092,6 +9170,9 @@ packages:
linkifyjs@4.3.3:
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
listenercount@1.0.1:
resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==}
listhen@1.10.1:
resolution: {integrity: sha512-6nt/86SkqUQSLW1ofz8MxC6RhRMqOl3ONISe6qqvJ3xj09aJWQx6DhgSZpugs3PX4PXdOas/WD6A9jx6J2N19A==}
hasBin: true
@@ -9137,9 +9218,21 @@ packages:
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
lodash.difference@4.5.0:
resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==}
lodash.escaperegexp@4.1.2:
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
lodash.flatten@4.4.0:
resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
lodash.groupby@4.6.0:
resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
@@ -9150,9 +9243,15 @@ packages:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
lodash.isfunction@3.0.9:
resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==}
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnil@4.0.0:
resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
@@ -9162,6 +9261,9 @@ packages:
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.isundefined@3.0.1:
resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -9174,6 +9276,12 @@ packages:
lodash.truncate@4.4.2:
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
lodash.union@4.6.0:
resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==}
lodash.uniq@4.5.0:
resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
@@ -10124,6 +10232,10 @@ packages:
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readable-stream@4.7.0:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -10490,6 +10602,10 @@ packages:
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
engines: {node: '>=11.0.0'}
saxes@5.0.1:
resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==}
engines: {node: '>=10'}
scroll-into-view-if-needed@2.2.31:
resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==}
@@ -10957,6 +11073,10 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tar-stream@3.2.0:
resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
@@ -11069,6 +11189,9 @@ packages:
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
traverse@0.3.9:
resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -11481,6 +11604,9 @@ packages:
unwasm@0.5.3:
resolution: {integrity: sha512-keBgTSfp3r6+s9ZcSma+0chwxQdmLbB5+dAD9vjtB21UTMYuKAxHXCU1K2CbCtnP09EaWeRvACnXk0EJtUx+hw==}
unzipper@0.10.14:
resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==}
unzipper@0.12.5:
resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==}
@@ -11510,6 +11636,10 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
valibot@1.4.2:
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
peerDependencies:
@@ -12000,6 +12130,9 @@ packages:
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
engines: {node: '>=8.0'}
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
@@ -12081,6 +12214,10 @@ packages:
yuku-parser@0.8.4:
resolution: {integrity: sha512-sw41wouvT5rUmLIp87hmvm5vtF+MRSI3x6yjq6xqpYmtkQj+Ht6N7xRQ8lMhLv8N7JAzughGj0Rfi0jQRSu9HQ==}
zip-stream@4.1.1:
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
engines: {node: '>= 10'}
zip-stream@6.0.1:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
@@ -13848,6 +13985,25 @@ snapshots:
'@faker-js/faker@10.5.0': {}
'@fast-csv/format@4.3.5':
dependencies:
'@types/node': 14.18.63
lodash.escaperegexp: 4.1.2
lodash.isboolean: 3.0.3
lodash.isequal: 4.5.0
lodash.isfunction: 3.0.9
lodash.isnil: 4.0.0
'@fast-csv/parse@4.3.6':
dependencies:
'@types/node': 14.18.63
lodash.escaperegexp: 4.1.2
lodash.groupby: 4.6.0
lodash.isfunction: 3.0.9
lodash.isnil: 4.0.0
lodash.isundefined: 3.0.1
lodash.uniq: 4.5.0
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
@@ -15502,6 +15658,8 @@ snapshots:
'@types/node@12.20.55': {}
'@types/node@14.18.63': {}
'@types/node@24.13.3':
dependencies:
undici-types: 7.18.2
@@ -16524,6 +16682,32 @@ snapshots:
transitivePeerDependencies:
- supports-color
archiver-utils@2.1.0:
dependencies:
glob: 7.2.3
graceful-fs: 4.2.11
lazystream: 1.0.1
lodash.defaults: 4.2.0
lodash.difference: 4.5.0
lodash.flatten: 4.4.0
lodash.isplainobject: 4.0.6
lodash.union: 4.6.0
normalize-path: 3.0.0
readable-stream: 2.3.8
archiver-utils@3.0.4:
dependencies:
glob: 7.2.3
graceful-fs: 4.2.11
lazystream: 1.0.1
lodash.defaults: 4.2.0
lodash.difference: 4.5.0
lodash.flatten: 4.4.0
lodash.isplainobject: 4.0.6
lodash.union: 4.6.0
normalize-path: 3.0.0
readable-stream: 3.6.2
archiver-utils@5.0.2:
dependencies:
glob: 10.4.5
@@ -16534,6 +16718,16 @@ snapshots:
normalize-path: 3.0.0
readable-stream: 4.7.0
archiver@5.3.2:
dependencies:
archiver-utils: 2.1.0
async: 3.2.6
buffer-crc32: 0.2.13
readable-stream: 3.6.2
readdir-glob: 1.1.3
tar-stream: 2.2.0
zip-stream: 4.1.1
archiver@7.0.1:
dependencies:
archiver-utils: 5.0.2
@@ -16728,8 +16922,15 @@ snapshots:
dependencies:
is-windows: 1.0.2
big-integer@1.6.52: {}
bignumber.js@9.3.1: {}
binary@0.3.0:
dependencies:
buffers: 0.1.1
chainsaw: 0.1.0
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
@@ -16738,6 +16939,14 @@ snapshots:
birpc@4.1.0: {}
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
bluebird@3.4.7: {}
bluebird@3.7.2: {}
boolbase@1.0.0: {}
@@ -16781,6 +16990,8 @@ snapshots:
node-releases: 2.0.53
update-browserslist-db: 1.3.0(browserslist@4.28.8)
buffer-crc32@0.2.13: {}
buffer-crc32@1.0.0: {}
buffer-equal-constant-time@1.0.1: {}
@@ -16791,11 +17002,20 @@ snapshots:
dependencies:
'@types/node': 26.2.0
buffer-indexof-polyfill@1.0.2: {}
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
buffers@0.1.1: {}
builder-util-runtime@9.7.0(supports-color@10.2.2):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
@@ -16903,6 +17123,10 @@ snapshots:
chai@6.2.2: {}
chainsaw@0.1.0:
dependencies:
traverse: 0.3.9
chalk-template@1.1.2:
dependencies:
chalk: 5.6.2
@@ -17074,6 +17298,13 @@ snapshots:
compatx@0.2.0: {}
compress-commons@4.1.2:
dependencies:
buffer-crc32: 0.2.13
crc32-stream: 4.0.3
normalize-path: 3.0.0
readable-stream: 3.6.2
compress-commons@6.0.2:
dependencies:
crc-32: 1.2.2
@@ -17177,6 +17408,11 @@ snapshots:
crc-32@1.2.2: {}
crc32-stream@4.0.3:
dependencies:
crc-32: 1.2.2
readable-stream: 3.6.2
crc32-stream@6.0.0:
dependencies:
crc-32: 1.2.2
@@ -18135,6 +18371,18 @@ snapshots:
evtd@0.2.4: {}
exceljs@4.4.0:
dependencies:
archiver: 5.3.2
dayjs: 1.11.21
fast-csv: 4.3.6
jszip: 3.10.1
readable-stream: 3.6.2
saxes: 5.0.1
tmp: 0.2.7
unzipper: 0.10.14
uuid: 8.3.2
execa@10.0.1:
dependencies:
'@sindresorhus/merge-streams': 4.0.0
@@ -18177,6 +18425,11 @@ snapshots:
extendable-error@0.1.7: {}
fast-csv@4.3.6:
dependencies:
'@fast-csv/format': 4.3.5
'@fast-csv/parse': 4.3.6
fast-deep-equal@3.1.3: {}
fast-equals@6.0.2: {}
@@ -18308,6 +18561,8 @@ snapshots:
fresh@2.0.0: {}
fs-constants@1.0.0: {}
fs-extra@10.1.0:
dependencies:
graceful-fs: 4.2.11
@@ -18353,6 +18608,13 @@ snapshots:
fsevents@2.3.3:
optional: true
fstream@1.0.12:
dependencies:
graceful-fs: 4.2.11
inherits: 2.0.4
mkdirp: 0.5.6
rimraf: 2.6.3
function-bind@1.1.2: {}
function.prototype.name@1.2.0:
@@ -19353,6 +19615,8 @@ snapshots:
linkifyjs@4.3.3: {}
listenercount@1.0.1: {}
listhen@1.10.1(@parcel/watcher@2.6.0):
dependencies:
'@parcel/watcher-wasm': 2.6.0
@@ -19412,22 +19676,36 @@ snapshots:
lodash.debounce@4.0.8: {}
lodash.defaults@4.2.0: {}
lodash.difference@4.5.0: {}
lodash.escaperegexp@4.1.2: {}
lodash.flatten@4.4.0: {}
lodash.groupby@4.6.0: {}
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
lodash.isequal@4.5.0: {}
lodash.isfunction@3.0.9: {}
lodash.isinteger@4.0.4: {}
lodash.isnil@4.0.0: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.isundefined@3.0.1: {}
lodash.merge@4.6.2:
optional: true
@@ -19437,6 +19715,10 @@ snapshots:
lodash.truncate@4.4.2: {}
lodash.union@4.6.0: {}
lodash.uniq@4.5.0: {}
lodash@4.18.1: {}
log-symbols@7.0.1:
@@ -20563,6 +20845,12 @@ snapshots:
string_decoder: 1.1.1
util-deprecate: 1.0.2
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
readable-stream@4.7.0:
dependencies:
abort-controller: 3.0.0
@@ -20960,6 +21248,10 @@ snapshots:
sax@1.6.1: {}
saxes@5.0.1:
dependencies:
xmlchars: 2.2.0
scroll-into-view-if-needed@2.2.31:
dependencies:
compute-scroll-into-view: 1.0.20
@@ -21496,6 +21788,14 @@ snapshots:
tapable@2.3.3: {}
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
tar-stream@3.2.0:
dependencies:
b4a: 1.8.1
@@ -21625,6 +21925,8 @@ snapshots:
tr46@0.0.3: {}
traverse@0.3.9: {}
tree-kill@1.2.2: {}
treemate@0.3.11: {}
@@ -22010,6 +22312,19 @@ snapshots:
pathe: 2.0.3
pkg-types: 2.3.1
unzipper@0.10.14:
dependencies:
big-integer: 1.6.52
binary: 0.3.0
bluebird: 3.4.7
buffer-indexof-polyfill: 1.0.2
duplexer2: 0.1.4
fstream: 1.0.12
graceful-fs: 4.2.11
listenercount: 1.0.1
readable-stream: 2.3.8
setimmediate: 1.0.5
unzipper@0.12.5:
dependencies:
bluebird: 3.7.2
@@ -22049,6 +22364,8 @@ snapshots:
util-deprecate@1.0.2: {}
uuid@8.3.2: {}
valibot@1.4.2(typescript@6.0.3):
optionalDependencies:
typescript: 6.0.3
@@ -22652,6 +22969,8 @@ snapshots:
xmlbuilder@15.1.1: {}
xmlchars@2.2.0: {}
y18n@4.0.3: {}
y18n@5.0.8: {}
@@ -22774,6 +23093,12 @@ snapshots:
'@yuku-parser/binding-win32-arm64': 0.8.4
'@yuku-parser/binding-win32-x64': 0.8.4
zip-stream@4.1.1:
dependencies:
archiver-utils: 3.0.4
compress-commons: 4.1.2
readable-stream: 3.6.2
zip-stream@6.0.1:
dependencies:
archiver-utils: 5.0.2