|
|
@@ -1,43 +1,246 @@
|
|
|
<script setup lang="ts">
|
|
|
/**
|
|
|
- * 分账页(views/academic/divide/index.vue)—— 分账树(菜单「分账」,端点 38,树形非分页)。
|
|
|
- * el-table tree-props 渲染上级→下线层级(最多 3 层);quota 为金额(每次分成金额)。
|
|
|
+ * 分账页(views/academic/divide/index.vue)—— 分账树(端点 38)+ 写操作(端点 39/40)+ 分账流水(端点 41/42/43)。
|
|
|
+ *
|
|
|
+ * 结构(自上而下):
|
|
|
+ * ① 分账树(el-table tree-props,端点 38;行内"设置额度""添加下线");
|
|
|
+ * ② 设置额度对话框(端点 40,3111 父子额度约束:子额度 ≤ 父额度);
|
|
|
+ * ③ 添加下线对话框(端点 39;新节点默认 quota=0.00 → 成功后引导设置额度);
|
|
|
+ * ④ 分账流水(端点 43 列表 + 端点 41 新增 + 端点 42 删除,ListPage 分页)。
|
|
|
+ *
|
|
|
+ * 纪律(契约 v1.9 §2.3):
|
|
|
+ * - 分账是树形(上级→下线,最多 3 层);quota 为**金额**(每次分成金额,0.00 = 该链不分成);
|
|
|
+ * - 端点 39/40 角色 super_admin/academic;端点 41/42 同;端点 43 列表多一个 finance 可查;
|
|
|
+ * - 3111(子额度>父额度 / 层级超限)读 code 直显 message;
|
|
|
+ * - 写操作恒 200 + body code,读 code 判定;金额字符串两位小数;时间 RFC3339。
|
|
|
*/
|
|
|
-import { getDivideTree, type DivideNodeDTO } from '@/api/academic/divide-into';
|
|
|
+import { getDivideTree, addDivideMember, setDivideQuota, createDivideLog, deleteDivideLog, listDivideLogs } from '@/api/academic/divide-into';
|
|
|
+import type { DivideNodeDTO, DivideLogDTO } from '@/api/academic/divide-into';
|
|
|
+import { listMembers } from '@/api/org/member';
|
|
|
+import type { MemberDTO } from '@/api/org/member';
|
|
|
+import ListPage from '@/components/ListPage.vue';
|
|
|
+import type { ListColumn } from '@/components/list-column';
|
|
|
+import { useListPage } from '@/composables/use-list-page';
|
|
|
import { useScope } from '@/store/scope';
|
|
|
-import { formatMoney } from '@/utils/format';
|
|
|
+import { formatMoney, formatDateTime } from '@/utils/format';
|
|
|
|
|
|
const scope = useScope();
|
|
|
+
|
|
|
+/** 命令式 EP(UMD 全局,http.ts 同款读法) */
|
|
|
+function ElMessage_(type: 'success' | 'warning' | 'error', text: string): void {
|
|
|
+ const M = (globalThis.ElementPlus ?? {}).ElMessage;
|
|
|
+ if (M) M({ type, message: text });
|
|
|
+ else console.warn(`[${type}]`, text);
|
|
|
+}
|
|
|
+function ElConfirm(text: string): Promise<void> {
|
|
|
+ const B = (globalThis.ElementPlus ?? {}).ElMessageBox;
|
|
|
+ if (!B) return Promise.resolve();
|
|
|
+ return B.confirm(text, '操作确认', { confirmButtonText: '执行', cancelButtonText: '取消', type: 'warning' })
|
|
|
+ .then(() => undefined)
|
|
|
+ .catch(() => Promise.reject(new Error('cancel')));
|
|
|
+}
|
|
|
+
|
|
|
+/* ================= 成员选项(学生,机构级全量——分账树/流水跨校区) ================= */
|
|
|
+const studentOpts = ref<MemberDTO[]>([]);
|
|
|
+const studentName = (id: string | null): string => (id ? studentOpts.value.find((m) => m.userId === id)?.realName ?? id : '—');
|
|
|
+onMounted(async () => {
|
|
|
+ const r = await listMembers({ institutionId: scope.current.institutionId, personType: 'student', pageSize: 200 });
|
|
|
+ if (r.code === 200) studentOpts.value = r.data?.list ?? [];
|
|
|
+});
|
|
|
+
|
|
|
+/* ================= ① 分账树(端点 38) ================= */
|
|
|
const tree = ref<DivideNodeDTO[]>([]);
|
|
|
-const loading = ref(false);
|
|
|
-const error = ref('');
|
|
|
+const treeLoading = ref(false);
|
|
|
+const treeError = ref('');
|
|
|
|
|
|
-async function load(): Promise<void> {
|
|
|
- loading.value = true;
|
|
|
- error.value = '';
|
|
|
+async function loadTree(): Promise<void> {
|
|
|
+ treeLoading.value = true;
|
|
|
+ treeError.value = '';
|
|
|
try {
|
|
|
const r = await getDivideTree(scope.toQuery());
|
|
|
if (r.code === 200 && r.data) tree.value = r.data.tree;
|
|
|
- else error.value = r.message || '加载失败';
|
|
|
+ else treeError.value = r.message || '加载失败';
|
|
|
} catch {
|
|
|
- error.value = '网络连接异常';
|
|
|
+ treeError.value = '网络连接异常';
|
|
|
} finally {
|
|
|
- loading.value = false;
|
|
|
+ treeLoading.value = false;
|
|
|
}
|
|
|
}
|
|
|
onMounted(() => {
|
|
|
- void load();
|
|
|
+ void loadTree();
|
|
|
});
|
|
|
+
|
|
|
+/** 树拍平为「userId → 姓名」选项(添加下线/流水选分账人用;含全层级)。 */
|
|
|
+function flatten(nodes: DivideNodeDTO[]): { userId: string; realName: string }[] {
|
|
|
+ const out: { userId: string; realName: string }[] = [];
|
|
|
+ const walk = (ns: DivideNodeDTO[]): void => {
|
|
|
+ for (const n of ns) {
|
|
|
+ out.push({ userId: n.userId, realName: n.realName });
|
|
|
+ walk(n.children);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ walk(nodes);
|
|
|
+ return out;
|
|
|
+}
|
|
|
+const treeMembers = computed(() => flatten(tree.value));
|
|
|
+
|
|
|
+/* ================= ② 设置额度(端点 40) ================= */
|
|
|
+const quotaVisible = ref(false);
|
|
|
+const quotaBusy = ref(false);
|
|
|
+const quotaTarget = ref<DivideNodeDTO | null>(null);
|
|
|
+const quotaForm = reactive({ quota: '' });
|
|
|
+function openQuota(node: DivideNodeDTO): void {
|
|
|
+ quotaTarget.value = node;
|
|
|
+ quotaForm.quota = node.quota;
|
|
|
+ quotaVisible.value = true;
|
|
|
+}
|
|
|
+async function doSetQuota(): Promise<void> {
|
|
|
+ const node = quotaTarget.value;
|
|
|
+ if (!node) return;
|
|
|
+ quotaBusy.value = true;
|
|
|
+ try {
|
|
|
+ const r = await setDivideQuota(node.userId, { quota: quotaForm.quota });
|
|
|
+ if (r.code === 200) {
|
|
|
+ ElMessage_('success', '额度已更新');
|
|
|
+ quotaVisible.value = false;
|
|
|
+ void loadTree();
|
|
|
+ } else {
|
|
|
+ // 3111 子额度>父额度 / 2002 格式错:读 code 直显
|
|
|
+ ElMessage_('warning', r.message || `设置失败(code ${r.code})`);
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ ElMessage_('error', '网络连接异常');
|
|
|
+ } finally {
|
|
|
+ quotaBusy.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* ================= ③ 添加下线(端点 39;新节点默认 quota=0.00 → 引导设额度) ================= */
|
|
|
+const memberVisible = ref(false);
|
|
|
+const memberBusy = ref(false);
|
|
|
+const memberTarget = ref<DivideNodeDTO | null>(null);
|
|
|
+const memberForm = reactive({ subUserId: '' });
|
|
|
+function openAddMember(node: DivideNodeDTO): void {
|
|
|
+ memberTarget.value = node;
|
|
|
+ memberForm.subUserId = studentOpts.value[0]?.userId ?? '';
|
|
|
+ memberVisible.value = true;
|
|
|
+}
|
|
|
+async function doAddMember(): Promise<void> {
|
|
|
+ const node = memberTarget.value;
|
|
|
+ if (!node) return;
|
|
|
+ if (!memberForm.subUserId) {
|
|
|
+ ElMessage_('warning', '请选择下线成员');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ memberBusy.value = true;
|
|
|
+ try {
|
|
|
+ const r = await addDivideMember({ parentUserId: node.userId, subUserId: memberForm.subUserId });
|
|
|
+ if (r.code === 200) {
|
|
|
+ // 契约 §2.3:新节点默认 quota=0.00,不设置额度则该链不分成且不报错 → 引导设置
|
|
|
+ ElMessage_('success', '下线已添加(默认额度 0.00,请尽快设置额度,否则该链不产生分成)');
|
|
|
+ memberVisible.value = false;
|
|
|
+ await loadTree();
|
|
|
+ void loadLogs();
|
|
|
+ } else {
|
|
|
+ // 3111 层级超限 / 3140 已在树中 / 3001 不存在:读 code 直显
|
|
|
+ ElMessage_('warning', r.message || `添加失败(code ${r.code})`);
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ ElMessage_('error', '网络连接异常');
|
|
|
+ } finally {
|
|
|
+ memberBusy.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* ================= ④ 分账流水(端点 41/42/43) ================= */
|
|
|
+const { rows: logs, total: logTotal, page: logPage, pageSize: logPageSize, loading: logLoading, error: logError, fetchList: loadLogs } =
|
|
|
+ useListPage<DivideLogDTO>({
|
|
|
+ fetcher: (p, ps) => listDivideLogs({ ...scope.toQuery(), page: p, pageSize: ps }),
|
|
|
+ });
|
|
|
+onMounted(() => {
|
|
|
+ void loadLogs();
|
|
|
+});
|
|
|
+const logColumns: ListColumn<DivideLogDTO>[] = [
|
|
|
+ { prop: 'id', label: '流水 id', minWidth: 90 },
|
|
|
+ {
|
|
|
+ prop: 'findUserId',
|
|
|
+ label: '分账人',
|
|
|
+ minWidth: 130,
|
|
|
+ formatter: (r) => {
|
|
|
+ const m = treeMembers.value.find((x) => x.userId === r.findUserId);
|
|
|
+ return m ? m.realName : r.findUserId;
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ prop: 'studentId',
|
|
|
+ label: '学生',
|
|
|
+ minWidth: 130,
|
|
|
+ formatter: (r) => studentName(r.studentId),
|
|
|
+ },
|
|
|
+ { prop: 'orderNo', label: '订单号', minWidth: 150 },
|
|
|
+ { prop: 'money', label: '金额', minWidth: 100 },
|
|
|
+ { prop: 'remark', label: '备注', minWidth: 140 },
|
|
|
+ { prop: 'createTime', label: '时间', minWidth: 160, formatter: (r) => formatDateTime(r.createTime) },
|
|
|
+];
|
|
|
+
|
|
|
+/** 记分账流水对话框(端点 41) */
|
|
|
+const logVisible = ref(false);
|
|
|
+const logBusy = ref(false);
|
|
|
+const logForm = reactive({ findUserId: '', studentId: '', orderNo: '', remark: '' });
|
|
|
+function openLog(): void {
|
|
|
+ logForm.findUserId = treeMembers.value[0]?.userId ?? '';
|
|
|
+ logForm.studentId = studentOpts.value[0]?.userId ?? '';
|
|
|
+ logForm.orderNo = `ORD${Date.now()}`;
|
|
|
+ logForm.remark = '';
|
|
|
+ logVisible.value = true;
|
|
|
+}
|
|
|
+async function doCreateLog(): Promise<void> {
|
|
|
+ if (!logForm.findUserId || !logForm.studentId || !logForm.orderNo) {
|
|
|
+ ElMessage_('warning', '请填全分账人/学生/订单号');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ logBusy.value = true;
|
|
|
+ try {
|
|
|
+ const r = await createDivideLog({ findUserId: logForm.findUserId, studentId: logForm.studentId, orderNo: logForm.orderNo, remark: logForm.remark || undefined });
|
|
|
+ if (r.code === 200) {
|
|
|
+ ElMessage_('success', '流水已记录');
|
|
|
+ logVisible.value = false;
|
|
|
+ void loadLogs();
|
|
|
+ } else {
|
|
|
+ ElMessage_('warning', r.message || `记录失败(code ${r.code})`);
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ ElMessage_('error', '网络连接异常');
|
|
|
+ } finally {
|
|
|
+ logBusy.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+/** 删分账流水(端点 42) */
|
|
|
+async function doDeleteLog(l: DivideLogDTO): Promise<void> {
|
|
|
+ try {
|
|
|
+ await ElConfirm(`确认删除订单 ${l.orderNo} 的分账流水?`);
|
|
|
+ } catch {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const r = await deleteDivideLog({ studentId: l.studentId, orderNo: l.orderNo });
|
|
|
+ if (r.code === 200) {
|
|
|
+ ElMessage_('success', '已删除');
|
|
|
+ void loadLogs();
|
|
|
+ } else {
|
|
|
+ ElMessage_('warning', r.message || `删除失败(code ${r.code})`);
|
|
|
+ }
|
|
|
+}
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
<div class="divide-page">
|
|
|
+ <!-- ① 分账树(端点 38) -->
|
|
|
<div class="divide-page__bar">
|
|
|
- <el-button @click="load" :loading="loading">刷新</el-button>
|
|
|
- <span class="divide-page__hint">分账树:上级 → 下线(最多 3 层);额度 0.00 = 该链不分成</span>
|
|
|
+ <el-button @click="loadTree" :loading="treeLoading">刷新</el-button>
|
|
|
+ <span class="divide-page__hint">分账树:上级 → 下线(最多 3 层);额度为金额(每次分成),0.00 = 该链不分成;子额度 ≤ 父额度</span>
|
|
|
</div>
|
|
|
- <el-alert v-if="error" :title="error" type="error" show-icon class="divide-page__error" />
|
|
|
- <el-table v-else :data="tree" :loading="loading" row-key="userId" :default-expand-all="true" :tree-props="{ children: 'children' }" border>
|
|
|
+ <el-alert v-if="treeError" :title="treeError" type="error" show-icon class="divide-page__error" />
|
|
|
+ <el-table v-else :data="tree" :loading="treeLoading" row-key="userId" :default-expand-all="true" :tree-props="{ children: 'children' }" border>
|
|
|
<el-table-column prop="realName" label="成员" min-width="160" />
|
|
|
<el-table-column label="分成额度(元)" min-width="130">
|
|
|
<template #default="{ row }">¥ {{ formatMoney(parseFloat(row.quota)) }}</template>
|
|
|
@@ -45,7 +248,98 @@ onMounted(() => {
|
|
|
<el-table-column label="累计收入(元)" min-width="130">
|
|
|
<template #default="{ row }">¥ {{ formatMoney(parseFloat(row.cumulativeIncome)) }}</template>
|
|
|
</el-table-column>
|
|
|
+ <el-table-column label="操作" min-width="170" fixed="right">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-button size="small" type="primary" @click="openQuota(row)">设置额度</el-button>
|
|
|
+ <el-button size="small" type="warning" @click="openAddMember(row)">添加下线</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
</el-table>
|
|
|
+
|
|
|
+ <!-- ④ 分账流水(端点 41/42/43) -->
|
|
|
+ <div class="divide-page__logs">
|
|
|
+ <ListPage
|
|
|
+ v-model:page="logPage"
|
|
|
+ v-model:page-size="logPageSize"
|
|
|
+ :rows="logs"
|
|
|
+ :total="logTotal"
|
|
|
+ :loading="logLoading"
|
|
|
+ :error="logError"
|
|
|
+ :columns="logColumns"
|
|
|
+ >
|
|
|
+ <template #toolbar>
|
|
|
+ <el-button type="primary" :disabled="treeMembers.length === 0" @click="openLog">记分账流水</el-button>
|
|
|
+ </template>
|
|
|
+ </ListPage>
|
|
|
+ <div class="divide-page__log-actions">
|
|
|
+ <div v-for="l in logs" :key="l.id" class="divide-page__log-row">
|
|
|
+ <span class="divide-page__log-no">{{ l.orderNo }}</span>
|
|
|
+ <el-button size="small" type="danger" @click="doDeleteLog(l)">删除</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- ② 设置额度对话框(端点 40) -->
|
|
|
+ <el-dialog v-model="quotaVisible" title="设置分成额度" width="420px">
|
|
|
+ <el-form label-width="100px">
|
|
|
+ <el-form-item label="成员">
|
|
|
+ <span>{{ quotaTarget?.realName }}</span>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="分成额度">
|
|
|
+ <el-input v-model="quotaForm.quota" placeholder="金额(如 100.00;0.00 = 不分成)" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-alert title="子额度不可超过父额度(3111);额度变化影响该节点及其全部下线" type="info" :closable="false" />
|
|
|
+ </el-form>
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="quotaVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" :loading="quotaBusy" @click="doSetQuota">保存</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- ③ 添加下线对话框(端点 39) -->
|
|
|
+ <el-dialog v-model="memberVisible" title="添加下线" width="440px">
|
|
|
+ <el-form label-width="100px">
|
|
|
+ <el-form-item label="上级">
|
|
|
+ <span>{{ memberTarget?.realName }}</span>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="下线成员">
|
|
|
+ <el-select v-model="memberForm.subUserId" filterable placeholder="选择成员" style="width: 100%">
|
|
|
+ <el-option v-for="m in studentOpts" :key="m.userId" :value="m.userId" :label="`${m.realName}(${m.phone})`" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-alert title="新节点默认额度 0.00;添加成功后请设置额度,否则该链不产生分成(不报错)" type="info" :closable="false" />
|
|
|
+ </el-form>
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="memberVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" :loading="memberBusy" @click="doAddMember">添加</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- 记分账流水对话框(端点 41) -->
|
|
|
+ <el-dialog v-model="logVisible" title="记分账流水" width="480px">
|
|
|
+ <el-form label-width="90px">
|
|
|
+ <el-form-item label="分账人">
|
|
|
+ <el-select v-model="logForm.findUserId" filterable placeholder="选择分账人(树内成员)" style="width: 100%">
|
|
|
+ <el-option v-for="m in treeMembers" :key="m.userId" :value="m.userId" :label="m.realName" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="学生">
|
|
|
+ <el-select v-model="logForm.studentId" filterable placeholder="选择学生" style="width: 100%">
|
|
|
+ <el-option v-for="m in studentOpts" :key="m.userId" :value="m.userId" :label="`${m.realName}(${m.phone})`" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="订单号">
|
|
|
+ <el-input v-model="logForm.orderNo" placeholder="订单号" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="备注">
|
|
|
+ <el-input v-model="logForm.remark" placeholder="可空" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="logVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" :loading="logBusy" @click="doCreateLog">记录</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
@@ -63,4 +357,25 @@ onMounted(() => {
|
|
|
font-size: var(--app-font-md);
|
|
|
color: var(--app-text-secondary, #8a8f9c);
|
|
|
}
|
|
|
+.divide-page__logs {
|
|
|
+ margin-top: 16px;
|
|
|
+ padding-top: 12px;
|
|
|
+ border-top: 1px solid var(--app-color-border, #e3e7ea);
|
|
|
+}
|
|
|
+.divide-page__log-actions {
|
|
|
+ margin-top: 12px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.divide-page__log-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.divide-page__log-no {
|
|
|
+ font-size: var(--app-font-md);
|
|
|
+ color: var(--app-color-text-2, #5d6672);
|
|
|
+ min-width: 160px;
|
|
|
+}
|
|
|
</style>
|