plus-ui/src/views/media/account/index.vue
i548450 f318fe2791 新增媒体账号管理页面(媒体接入 H 阶段)
- src/api/media/{account,app,oauth}:4 个账号接口 + app 列表 + 取授权 URL
- src/views/media/account/index.vue:列表 + 搜索 + 详情对话框 + 启停 + 新增绑定
- src/views/media/account/oauth-result/index.vue:OAuth 回调结果落地页,
  通过 window.opener.postMessage 通知父窗口刷新列表
- 路由 constantRoutes 加 hidden 路由 /media/account/oauth-result
- permission.ts whiteList 加上落地页路径,避免回调时被踢去 login

新增绑定交互:window.open 新窗口走授权,原列表保留状态;落地页发完成
事件后父窗口自动刷新;不删除账号(后端无 remove 端点)
2026-06-07 21:43:57 +08:00

325 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="p-2">
<transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
<div v-show="showSearch" class="mb-[10px]">
<el-card shadow="hover">
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item label="平台" prop="platform">
<el-select v-model="queryParams.platform" placeholder="请选择平台" clearable style="width: 160px">
<el-option v-for="p in platformOptions" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
</el-form-item>
<el-form-item label="健康度" prop="authStatus">
<el-select v-model="queryParams.authStatus" placeholder="请选择健康度" clearable style="width: 160px">
<el-option label="正常" value="HEALTHY" />
<el-option label="需重新授权" value="REAUTH_REQUIRED" />
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 160px">
<el-option label="启用" value="ENABLED" />
<el-option label="禁用" value="DISABLED" />
</el-select>
</el-form-item>
<el-form-item label="外部ID" prop="externalId">
<el-input v-model="queryParams.externalId" placeholder="open_id 等" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</transition>
<el-card shadow="hover">
<template #header>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button v-hasPermi="['media:account:bind']" type="primary" plain icon="Plus" @click="openBindDialog">新增绑定</el-button>
</el-col>
<right-toolbar v-model:show-search="showSearch" @query-table="getList"></right-toolbar>
</el-row>
</template>
<el-table v-loading="loading" border :data="accountList">
<el-table-column label="ID" align="center" prop="id" width="80" />
<el-table-column label="平台" align="center" prop="platform" width="100">
<template #default="scope">
<el-tag>{{ scope.row.platform }}</el-tag>
</template>
</el-table-column>
<el-table-column label="应用 ID" align="center" prop="appId" width="100" />
<el-table-column label="外部 ID" align="center" prop="externalId" show-overflow-tooltip />
<el-table-column label="状态" align="center" prop="status" width="80">
<template #default="scope">
<el-tag :type="scope.row.status === 'ENABLED' ? 'success' : 'info'">
{{ scope.row.status === 'ENABLED' ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="健康度" align="center" prop="authStatus" width="120">
<template #default="scope">
<el-tag :type="scope.row.authStatus === 'HEALTHY' ? 'success' : 'danger'">
{{ scope.row.authStatus === 'HEALTHY' ? '正常' : '需重新授权' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="scope" align="center" prop="scope" show-overflow-tooltip />
<el-table-column label="access_token" align="center" prop="accessTokenMasked" width="160" show-overflow-tooltip />
<el-table-column label="access_token 过期" align="center" prop="accessTokenExpiresAt" width="170">
<template #default="scope">{{ parseTime(scope.row.accessTokenExpiresAt) }}</template>
</el-table-column>
<el-table-column label="refresh_token 过期" align="center" prop="refreshTokenExpiresAt" width="170">
<template #default="scope">{{ parseTime(scope.row.refreshTokenExpiresAt) }}</template>
</el-table-column>
<el-table-column label="续期剩余" align="center" prop="refreshRenewRemainingCount" width="100" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="180">
<template #default="scope">
<el-tooltip content="详情" placement="top">
<el-button v-hasPermi="['media:account:query']" link type="primary" icon="View" @click="openDetail(scope.row)"></el-button>
</el-tooltip>
<el-tooltip v-if="scope.row.status === 'ENABLED'" content="禁用" placement="top">
<el-button v-hasPermi="['media:account:edit']" link type="warning" icon="CircleClose" @click="handleDisable(scope.row)"></el-button>
</el-tooltip>
<el-tooltip v-else content="启用" placement="top">
<el-button v-hasPermi="['media:account:edit']" link type="success" icon="CircleCheck" @click="handleEnable(scope.row)"></el-button>
</el-tooltip>
<el-tooltip v-if="scope.row.authStatus === 'REAUTH_REQUIRED'" content="重新授权" placement="top">
<el-button v-hasPermi="['media:account:bind']" link type="danger" icon="Refresh" @click="reauth(scope.row)"></el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" :total="total" @pagination="getList" />
</el-card>
<!-- 详情对话框 -->
<el-dialog v-model="detailDialog.visible" :title="`媒体账号详情 #${detailDialog.data?.id ?? ''}`" width="640px" append-to-body>
<el-descriptions v-if="detailDialog.data" :column="2" border>
<el-descriptions-item label="ID">{{ detailDialog.data.id }}</el-descriptions-item>
<el-descriptions-item label="租户">{{ detailDialog.data.tenantId }}</el-descriptions-item>
<el-descriptions-item label="平台">{{ detailDialog.data.platform }}</el-descriptions-item>
<el-descriptions-item label="应用 ID">{{ detailDialog.data.appId }}</el-descriptions-item>
<el-descriptions-item label="外部 ID" :span="2">{{ detailDialog.data.externalId }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ detailDialog.data.status === 'ENABLED' ? '启用' : '禁用' }}</el-descriptions-item>
<el-descriptions-item label="健康度">{{ detailDialog.data.authStatus === 'HEALTHY' ? '正常' : '需重新授权' }}</el-descriptions-item>
<el-descriptions-item label="scope" :span="2">{{ detailDialog.data.scope }}</el-descriptions-item>
<el-descriptions-item label="access_token">{{ detailDialog.data.accessTokenMasked }}</el-descriptions-item>
<el-descriptions-item label="access_token 过期">{{ parseTime(detailDialog.data.accessTokenExpiresAt) }}</el-descriptions-item>
<el-descriptions-item label="refresh_token">{{ detailDialog.data.refreshTokenMasked }}</el-descriptions-item>
<el-descriptions-item label="refresh_token 过期">{{ parseTime(detailDialog.data.refreshTokenExpiresAt) }}</el-descriptions-item>
<el-descriptions-item label="续期剩余">{{ detailDialog.data.refreshRenewRemainingCount }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ parseTime(detailDialog.data.createTime) }}</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">{{ parseTime(detailDialog.data.updateTime) }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<!-- 新增绑定对话框 -->
<el-dialog v-model="bindDialog.visible" title="新增媒体账号绑定" width="500px" append-to-body @closed="resetBindForm">
<el-form ref="bindFormRef" :model="bindForm" :rules="bindRules" label-width="100px">
<el-form-item label="平台" prop="platform">
<el-select v-model="bindForm.platform" placeholder="请选择平台" style="width: 100%" @change="onPlatformChange">
<el-option v-for="p in platformOptions" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
</el-form-item>
<el-form-item label="媒体应用" prop="mediaAppId">
<el-select v-model="bindForm.mediaAppId" placeholder="请先选择平台" :loading="bindDialog.appLoading" style="width: 100%">
<el-option
v-for="app in bindDialog.appList"
:key="app.id"
:label="`${app.clientKey} (id=${app.id})`"
:value="app.id"
:disabled="app.status !== 1"
/>
</el-select>
</el-form-item>
<el-alert
type="info"
:closable="false"
title="点击「前往授权」会打开新窗口跳到媒体平台授权页,授权完成后回到本系统的结果页"
show-icon
/>
</el-form>
<template #footer>
<el-button @click="bindDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="bindDialog.submitting" @click="submitBind">前往授权</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup name="MediaAccount" lang="ts">
import { listMediaAccount, getMediaAccount, disableMediaAccount, enableMediaAccount } from '@/api/media/account';
import { MediaAccountVO, MediaAccountQuery } from '@/api/media/account/types';
import { listMediaApp } from '@/api/media/app';
import { MediaAppVO } from '@/api/media/app/types';
import { getAuthorizeUrl } from '@/api/media/oauth';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const platformOptions = [{ value: 'DOUYIN', label: '抖音' }];
const accountList = ref<MediaAccountVO[]>([]);
const loading = ref(true);
const showSearch = ref(true);
const total = ref(0);
const queryFormRef = ref<ElFormInstance>();
const initQuery: MediaAccountQuery = {
pageNum: 1,
pageSize: 10,
platform: undefined,
status: undefined,
authStatus: undefined,
externalId: undefined
};
const queryParams = ref<MediaAccountQuery>({ ...initQuery });
const detailDialog = reactive<{ visible: boolean; data: MediaAccountVO | null }>({
visible: false,
data: null
});
const bindFormRef = ref<ElFormInstance>();
const bindDialog = reactive<{
visible: boolean;
appLoading: boolean;
appList: MediaAppVO[];
submitting: boolean;
}>({
visible: false,
appLoading: false,
appList: [],
submitting: false
});
const bindForm = reactive<{ platform: string; mediaAppId: string | number | undefined }>({
platform: 'DOUYIN',
mediaAppId: undefined
});
const bindRules = {
platform: [{ required: true, message: '请选择平台', trigger: 'change' }],
mediaAppId: [{ required: true, message: '请选择媒体应用', trigger: 'change' }]
};
const parseTime = (value?: string | number | Date) => (value ? proxy?.parseTime(value) : '-');
const getList = async () => {
loading.value = true;
try {
const res = await listMediaAccount(queryParams.value);
accountList.value = res.rows;
total.value = res.total;
} finally {
loading.value = false;
}
};
const handleQuery = () => {
queryParams.value.pageNum = 1;
getList();
};
const resetQuery = () => {
queryFormRef.value?.resetFields();
queryParams.value = { ...initQuery };
handleQuery();
};
const openDetail = async (row: MediaAccountVO) => {
const res = await getMediaAccount(row.id);
detailDialog.data = res.data;
detailDialog.visible = true;
};
const handleDisable = async (row: MediaAccountVO) => {
await proxy?.$modal.confirm(`确认禁用账号 #${row.id}${row.externalId}?`);
await disableMediaAccount(row.id);
proxy?.$modal.msgSuccess('禁用成功');
await getList();
};
const handleEnable = async (row: MediaAccountVO) => {
await proxy?.$modal.confirm(`确认启用账号 #${row.id}${row.externalId}?`);
await enableMediaAccount(row.id);
proxy?.$modal.msgSuccess('启用成功');
await getList();
};
const reauth = async (row: MediaAccountVO) => {
bindForm.platform = row.platform;
bindForm.mediaAppId = row.appId;
await loadApps(row.platform);
bindDialog.visible = true;
};
const openBindDialog = async () => {
bindForm.platform = 'DOUYIN';
bindForm.mediaAppId = undefined;
await loadApps('DOUYIN');
bindDialog.visible = true;
};
const onPlatformChange = async (value: string) => {
bindForm.mediaAppId = undefined;
await loadApps(value);
};
const loadApps = async (platform: string) => {
bindDialog.appLoading = true;
try {
const res = await listMediaApp(platform);
bindDialog.appList = res.data || [];
} finally {
bindDialog.appLoading = false;
}
};
const resetBindForm = () => {
bindFormRef.value?.resetFields();
bindDialog.appList = [];
bindDialog.submitting = false;
};
const submitBind = () => {
bindFormRef.value?.validate(async (valid: boolean) => {
if (!valid) return;
bindDialog.submitting = true;
try {
const res = await getAuthorizeUrl({
platform: bindForm.platform,
mediaAppId: bindForm.mediaAppId as string | number
});
const url = res.data;
if (!url) {
proxy?.$modal.msgError('未获取到授权地址');
return;
}
window.open(url, '_blank', 'noopener=no');
bindDialog.visible = false;
proxy?.$modal.msgSuccess('已在新窗口打开授权页,请完成授权后回到本页');
} finally {
bindDialog.submitting = false;
}
});
};
// 监听 OAuth 落地页发来的完成消息,自动刷新列表
const onOauthMessage = (event: MessageEvent) => {
if (event.data === 'media-oauth-done') {
getList();
}
};
onMounted(() => {
window.addEventListener('message', onOauthMessage);
getList();
});
onBeforeUnmount(() => {
window.removeEventListener('message', onOauthMessage);
});
</script>