新增媒体账号管理页面(媒体接入 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 端点)
This commit is contained in:
i548450 2026-06-07 21:43:57 +08:00
parent 9fd2b6f137
commit f318fe2791
10 changed files with 567 additions and 1 deletions

View File

@ -0,0 +1,36 @@
import request from '@/utils/request';
import { AxiosPromise } from 'axios';
import { MediaAccountVO, MediaAccountQuery } from '@/api/media/account/types';
/** 分页查询媒体账号 */
export const listMediaAccount = (query?: MediaAccountQuery): AxiosPromise<MediaAccountVO[]> => {
return request({
url: '/media/account/list',
method: 'get',
params: query
});
};
/** 查询媒体账号详情token 已脱敏) */
export const getMediaAccount = (id: string | number): AxiosPromise<MediaAccountVO> => {
return request({
url: '/media/account/' + id,
method: 'get'
});
};
/** 禁用媒体账号 */
export const disableMediaAccount = (id: string | number) => {
return request({
url: '/media/account/' + id + '/disable',
method: 'put'
});
};
/** 启用媒体账号 */
export const enableMediaAccount = (id: string | number) => {
return request({
url: '/media/account/' + id + '/enable',
method: 'put'
});
};

View File

@ -0,0 +1,27 @@
export interface MediaAccountVO {
id: string | number;
tenantId: string;
platform: string;
appId: string | number;
externalId: string;
/** ENABLED / DISABLED */
status: string;
/** HEALTHY / REAUTH_REQUIRED */
authStatus: string;
accessTokenMasked?: string;
accessTokenExpiresAt?: string;
refreshTokenMasked?: string;
refreshTokenExpiresAt?: string;
scope?: string;
refreshRenewRemainingCount?: number;
createTime?: string;
updateTime?: string;
}
export interface MediaAccountQuery extends PageQuery {
platform?: string;
appId?: string | number;
status?: string;
authStatus?: string;
externalId?: string;
}

View File

@ -0,0 +1,12 @@
import request from '@/utils/request';
import { AxiosPromise } from 'axios';
import { MediaAppVO } from '@/api/media/app/types';
/** 列出可用媒体应用(按平台过滤) */
export const listMediaApp = (platform?: string): AxiosPromise<MediaAppVO[]> => {
return request({
url: '/media/app/list',
method: 'get',
params: { platform }
});
};

View File

@ -0,0 +1,7 @@
export interface MediaAppVO {
id: string | number;
platform: string;
clientKey: string;
/** 0=禁用 1=启用 */
status: number;
}

View File

@ -0,0 +1,12 @@
import request from '@/utils/request';
import { AxiosPromise } from 'axios';
import { AuthorizeUrlQuery } from '@/api/media/oauth/types';
/** 取媒体平台 OAuth 授权 URL */
export const getAuthorizeUrl = (query: AuthorizeUrlQuery): AxiosPromise<string> => {
return request({
url: '/media/oauth/authorize-url',
method: 'get',
params: query
});
};

View File

@ -0,0 +1,4 @@
export interface AuthorizeUrlQuery {
platform: string;
mediaAppId: string | number;
}

View File

@ -11,7 +11,7 @@ import { usePermissionStore } from '@/store/modules/permission';
import { ElMessage } from 'element-plus/es';
NProgress.configure({ showSpinner: false });
const whiteList = ['/login', '/register', '/social-callback', '/register*', '/register/*'];
const whiteList = ['/login', '/register', '/social-callback', '/register*', '/register/*', '/media/account/oauth-result'];
const isWhiteList = (path: string) => {
return whiteList.some((pattern) => isPathMatch(pattern, path));

View File

@ -42,6 +42,11 @@ export const constantRoutes: RouteRecordRaw[] = [
hidden: true,
component: () => import('@/layout/components/SocialCallback/index.vue')
},
{
path: '/media/account/oauth-result',
hidden: true,
component: () => import('@/views/media/account/oauth-result/index.vue')
},
{
path: '/login',
component: () => import('@/views/login.vue'),

View File

@ -0,0 +1,324 @@
<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>

View File

@ -0,0 +1,139 @@
<template>
<div class="oauth-result-wrapper">
<el-card class="result-card" shadow="hover">
<div v-if="status === 'success'" class="result-success">
<el-icon class="icon-success"><CircleCheckFilled /></el-icon>
<h2>授权成功</h2>
<p class="hint">媒体账号已绑定到当前租户</p>
<el-descriptions :column="1" border class="info">
<el-descriptions-item label="平台">{{ platform }}</el-descriptions-item>
<el-descriptions-item label="账号 ID">{{ accountId }}</el-descriptions-item>
</el-descriptions>
</div>
<div v-else class="result-fail">
<el-icon class="icon-fail"><CircleCloseFilled /></el-icon>
<h2>授权失败</h2>
<p class="hint">{{ failMessage }}</p>
<p class="error-code">错误码{{ errorCode || '未知' }}</p>
</div>
<div class="actions">
<el-button type="primary" @click="closeWindow">关闭窗口</el-button>
<el-button @click="goAccountList">返回账号列表</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts" name="MediaOAuthResult">
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
const route = useRoute();
const router = useRouter();
const status = computed(() => (route.query.status as string) || 'fail');
const platform = computed(() => (route.query.platform as string) || '-');
const accountId = computed(() => (route.query.accountId as string) || '-');
const errorCode = computed(() => (route.query.code as string) || '');
const ERROR_MESSAGES: Record<string, string> = {
USER_CANCELED: '用户取消了授权',
STATE_INVALID: 'state 无效或解析失败,请重新发起授权',
STATE_EXPIRED: 'state 已过期,请重新发起授权',
STATE_PLATFORM_MISMATCH: 'state 与回调平台不匹配',
ACCOUNT_BOUND_TO_OTHER_TENANT: '该媒体账号已被其他租户绑定',
APP_NOT_FOUND: '找不到对应的媒体应用',
APP_DISABLED: '媒体应用已禁用',
SIGNATURE_FAILED: '媒体平台签名校验失败',
INVALID_RESPONSE: '媒体平台返回数据格式异常',
TRANSIENT_NETWORK: '调用媒体平台网络异常',
TRANSIENT_PLATFORM_5XX: '媒体平台服务异常',
TRANSIENT_RATE_LIMIT: '媒体平台请求被限流',
OAUTH_REAUTH_REQUIRED: '授权失效,请重新授权',
RENEW_QUOTA_EXHAUSTED: '续期额度已耗尽,请重新授权',
UNKNOWN_PLATFORM: '未注册的媒体平台',
UNSUPPORTED_TOKEN_MODE: '不支持的 token 模式'
};
const failMessage = computed(() => ERROR_MESSAGES[errorCode.value] || '授权过程发生未知错误');
const closeWindow = () => {
window.close();
};
const goAccountList = () => {
// OAuth opener
if (window.opener) {
window.close();
} else {
router.push('/media/account');
}
};
onMounted(() => {
//
if (status.value === 'success' && window.opener) {
try {
window.opener.postMessage('media-oauth-done', '*');
} catch {
// ignore cross-origin error
}
}
});
</script>
<style scoped lang="scss">
.oauth-result-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f5f7fa;
}
.result-card {
width: 480px;
text-align: center;
padding: 24px 16px;
}
.icon-success,
.icon-fail {
font-size: 64px;
margin-bottom: 12px;
}
.icon-success {
color: #67c23a;
}
.icon-fail {
color: #f56c6c;
}
h2 {
margin: 12px 0;
}
.hint {
color: #606266;
margin-bottom: 16px;
}
.error-code {
color: #909399;
font-size: 13px;
margin-bottom: 12px;
}
.info {
text-align: left;
margin-top: 16px;
}
.actions {
margin-top: 24px;
display: flex;
justify-content: center;
gap: 12px;
}
</style>