feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
@@ -187,7 +187,7 @@
|
||||
:aria-label="t('google')"
|
||||
@click="handleSocialLogin('google')"
|
||||
>
|
||||
<text class="social-badge-icon google-icon">G</text>
|
||||
<image class="social-logo" src="/static/icons/google.svg" mode="aspectFit" />
|
||||
<text class="social-pill-text">{{ t('google') }}</text>
|
||||
</view>
|
||||
<view
|
||||
@@ -196,7 +196,7 @@
|
||||
:aria-label="t('apple')"
|
||||
@click="handleSocialLogin('apple')"
|
||||
>
|
||||
<text class="social-badge-icon apple-icon"></text>
|
||||
<image class="social-logo" src="/static/icons/apple.svg" mode="aspectFit" />
|
||||
<text class="social-pill-text">{{ t('apple') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -261,7 +261,7 @@ const pageTheme: Record<string, string> = {
|
||||
};
|
||||
|
||||
const state = reactive(createLoginState());
|
||||
const currentLocale = ref<string>(getCurrentLocale());
|
||||
const currentLocale = ref<OmniLocale>(getCurrentLocale());
|
||||
let countdownTimerId: number | null = null;
|
||||
|
||||
const pageRtlClass = computed((): string => {
|
||||
@@ -307,8 +307,8 @@ function handleAutofillTest(): void {
|
||||
fillTestAccount(state);
|
||||
}
|
||||
|
||||
function handleSendSmsCode(): void {
|
||||
const ok = sendSmsCode(state);
|
||||
async function handleSendSmsCode(): Promise<void> {
|
||||
const ok = await sendSmsCode(state);
|
||||
if (ok) {
|
||||
startCountdown();
|
||||
}
|
||||
@@ -333,13 +333,23 @@ function stopCountdown(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrimarySubmit(): void {
|
||||
/**
|
||||
* 主 CTA:仅交由 useLogin 处理;失败/无 token 时不会写本地凭证,也不会跳转。
|
||||
*/
|
||||
async function handlePrimarySubmit(): Promise<void> {
|
||||
if (state.submitting) {
|
||||
return;
|
||||
}
|
||||
if (state.tab === 'password') {
|
||||
submitPasswordLogin(state);
|
||||
} else if (state.tab === 'phone') {
|
||||
submitPhoneLogin(state);
|
||||
} else if (state.tab === 'email') {
|
||||
sendMagicLink(state);
|
||||
await submitPasswordLogin(state);
|
||||
return;
|
||||
}
|
||||
if (state.tab === 'phone') {
|
||||
await submitPhoneLogin(state);
|
||||
return;
|
||||
}
|
||||
if (state.tab === 'email') {
|
||||
await sendMagicLink(state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,10 +862,9 @@ onUnmounted(() => {
|
||||
background-color: #F1F5F9;
|
||||
}
|
||||
|
||||
.social-badge-icon {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--oc-text, #101828);
|
||||
.social-logo {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.social-pill-text {
|
||||
|
||||
@@ -45,7 +45,8 @@ export type LoginCopyKey =
|
||||
| 'countdownSuffix'
|
||||
| 'loginSuccess'
|
||||
| 'moreWaysComing'
|
||||
| 'loggingIn';
|
||||
| 'loggingIn'
|
||||
| 'sentSuccess';
|
||||
|
||||
export type LoginState = {
|
||||
tab: LoginTab;
|
||||
@@ -67,6 +68,14 @@ export type LoginAdminResponse = {
|
||||
refreshExpire?: number;
|
||||
};
|
||||
|
||||
/** 外部登录(短信 / Magic Link / 社交)统一响应契约 */
|
||||
export type ExternalAuthResponse = {
|
||||
status?: string;
|
||||
token?: string;
|
||||
url?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function isZhLocale(): boolean {
|
||||
return getCurrentLocale() === 'zh-CN';
|
||||
}
|
||||
@@ -156,6 +165,9 @@ export function resolveLoginText(key: LoginCopyKey | string, targetLocale?: stri
|
||||
if (key === 'comingSoon') {
|
||||
return zh ? '即将开放' : 'Coming soon';
|
||||
}
|
||||
if (key === 'sentSuccess') {
|
||||
return zh ? '已发送' : 'Sent';
|
||||
}
|
||||
if (key === 'phoneRequired') {
|
||||
return zh ? '请填写手机号' : 'Enter your phone number';
|
||||
}
|
||||
@@ -270,7 +282,8 @@ export function tickCountdown(state: LoginState): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录成功路径:写入 token / 可选 tenantId,再返回上一页或 toast
|
||||
* 登录成功路径:唯一允许写入本地 token 的入口。
|
||||
* 空 / 空白 token 直接拒绝,绝不 setAuthToken。
|
||||
*/
|
||||
export function completeLogin(token: string, tenantId?: string): boolean {
|
||||
const safeToken = token != null ? token.trim() : '';
|
||||
@@ -289,8 +302,16 @@ export function completeLogin(token: string, tenantId?: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function readPasswordLoginToken(res: LoginAdminResponse | null | undefined): string {
|
||||
if (res == null || res.token == null) {
|
||||
return '';
|
||||
}
|
||||
return String(res.token).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号密码真实登录:对齐后端 Cool-Admin POST /admin/base/open/login
|
||||
* 仅当接口明确返回非空 token 时写入本地凭证;失败 / 非成功 / 无 token 一律不写
|
||||
*/
|
||||
export async function submitPasswordLogin(state: LoginState): Promise<boolean> {
|
||||
const username = state.username != null ? state.username.trim() : '';
|
||||
@@ -320,14 +341,16 @@ export async function submitPasswordLogin(state: LoginState): Promise<boolean> {
|
||||
password,
|
||||
});
|
||||
state.submitting = false;
|
||||
if (res != null && res.token != null && res.token !== '') {
|
||||
completeLogin(res.token);
|
||||
return true;
|
||||
const token = readPasswordLoginToken(res);
|
||||
if (token !== '') {
|
||||
return completeLogin(token);
|
||||
}
|
||||
// 接口非失败体但缺少 token:不得 setAuthToken / 不得当成已登录
|
||||
state.errorMessage = '登录失败,未获取到有效凭证';
|
||||
toastNone(state.errorMessage);
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
// 业务失败 / 网络异常:绝不写入 token
|
||||
state.submitting = false;
|
||||
const msg = err && err.message ? err.message : '账户或密码不正确~';
|
||||
state.errorMessage = msg;
|
||||
@@ -335,7 +358,91 @@ export async function submitPasswordLogin(state: LoginState): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export function sendSmsCode(state: LoginState): boolean {
|
||||
function readExternalStatus(res: ExternalAuthResponse | null | undefined): string {
|
||||
if (res == null || res.status == null) {
|
||||
return '';
|
||||
}
|
||||
return String(res.status).trim();
|
||||
}
|
||||
|
||||
function readExternalToken(res: ExternalAuthResponse | null | undefined): string {
|
||||
if (res == null || res.token == null) {
|
||||
return '';
|
||||
}
|
||||
return String(res.token).trim();
|
||||
}
|
||||
|
||||
function readExternalUrl(res: ExternalAuthResponse | null | undefined): string {
|
||||
if (res == null || res.url == null) {
|
||||
return '';
|
||||
}
|
||||
return String(res.url).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理外部登录响应:not_configured → comingSoon;sent → 已发送;ok+token → 写凭证
|
||||
* 失败或不满足写 token 条件时绝不 setAuthToken
|
||||
*/
|
||||
function handleExternalAuthResult(
|
||||
res: ExternalAuthResponse | null | undefined,
|
||||
options?: { onSent?: () => void }
|
||||
): boolean {
|
||||
const status = readExternalStatus(res);
|
||||
if (status === 'not_configured') {
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
return false;
|
||||
}
|
||||
if (status === 'sent') {
|
||||
toastNone(resolveLoginText('sentSuccess'));
|
||||
if (options != null && options.onSent != null) {
|
||||
options.onSent();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (status === 'ok') {
|
||||
const token = readExternalToken(res);
|
||||
if (token !== '') {
|
||||
return completeLogin(token);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function openOrCopyAuthUrl(url: string): void {
|
||||
const safeUrl = url != null ? url.trim() : '';
|
||||
if (safeUrl === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
let opened = false;
|
||||
try {
|
||||
if (typeof window !== 'undefined' && typeof window.open === 'function') {
|
||||
window.open(safeUrl);
|
||||
opened = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
uni.setClipboardData({
|
||||
data: safeUrl,
|
||||
success: () => {
|
||||
if (!opened) {
|
||||
toastNone(safeUrl);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (!opened) {
|
||||
toastNone(safeUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码:POST /app/user/external/smsSend
|
||||
* sent 才启动倒计时;not_configured / 失败不写 token
|
||||
*/
|
||||
export async function sendSmsCode(state: LoginState): Promise<boolean> {
|
||||
if (state.countdown > 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -348,15 +455,32 @@ export function sendSmsCode(state: LoginState): boolean {
|
||||
}
|
||||
|
||||
state.errorMessage = '';
|
||||
state.countdown = 60;
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
return true;
|
||||
|
||||
try {
|
||||
const res = await httpPost<ExternalAuthResponse>('/app/user/external/smsSend', {
|
||||
phone,
|
||||
});
|
||||
return handleExternalAuthResult(res, {
|
||||
onSent: () => {
|
||||
state.countdown = 60;
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
const msg =
|
||||
err && err.message ? String(err.message) : resolveLoginText('comingSoon');
|
||||
state.errorMessage = msg;
|
||||
if (msg === resolveLoginText('comingSoon') || msg.indexOf('not_configured') >= 0) {
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 国际手机号验证码登录。空手机号不提交;第一版不伪造成功登录
|
||||
* 国际手机号验证码登录:POST /app/user/external/smsLogin
|
||||
* 仅 status=ok 且有 token 时写入凭证
|
||||
*/
|
||||
export function submitPhoneLogin(state: LoginState): boolean {
|
||||
export async function submitPhoneLogin(state: LoginState): Promise<boolean> {
|
||||
const phone = state.phone != null ? state.phone.trim() : '';
|
||||
if (phone === '') {
|
||||
state.errorMessage = resolveLoginText('phoneRequired');
|
||||
@@ -375,15 +499,29 @@ export function submitPhoneLogin(state: LoginState): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.submitting = true;
|
||||
state.errorMessage = '';
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
return false;
|
||||
|
||||
try {
|
||||
const res = await httpPost<ExternalAuthResponse>('/app/user/external/smsLogin', {
|
||||
phone,
|
||||
code: smsCode,
|
||||
});
|
||||
state.submitting = false;
|
||||
return handleExternalAuthResult(res);
|
||||
} catch (err: any) {
|
||||
state.submitting = false;
|
||||
const msg = err && err.message ? String(err.message) : resolveLoginText('comingSoon');
|
||||
state.errorMessage = msg;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱 Magic Link。空邮箱不提交;第一版不伪造成功登录
|
||||
* 邮箱 Magic Link:POST /app/user/external/magicLink
|
||||
* sent 提示已发送;ok+token 才登录;not_configured 不写 token
|
||||
*/
|
||||
export function sendMagicLink(state: LoginState): boolean {
|
||||
export async function sendMagicLink(state: LoginState): Promise<boolean> {
|
||||
const email = state.email != null ? state.email.trim() : '';
|
||||
if (email === '') {
|
||||
state.errorMessage = resolveLoginText('emailRequired');
|
||||
@@ -395,14 +533,56 @@ export function sendMagicLink(state: LoginState): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.submitting = true;
|
||||
state.errorMessage = '';
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
return false;
|
||||
|
||||
try {
|
||||
const res = await httpPost<ExternalAuthResponse>('/app/user/external/magicLink', {
|
||||
email,
|
||||
});
|
||||
state.submitting = false;
|
||||
return handleExternalAuthResult(res);
|
||||
} catch (err: any) {
|
||||
state.submitting = false;
|
||||
const msg = err && err.message ? String(err.message) : resolveLoginText('comingSoon');
|
||||
state.errorMessage = msg;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google / Apple 仅展示即将开放,不写凭证
|
||||
* Google / Apple:POST googleStart / appleStart
|
||||
* 有 url 则复制或打开;not_configured 且无 url 不打开假地址;不写 token
|
||||
*/
|
||||
export function tapSocialLogin(_provider: SocialProvider): void {
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
export async function tapSocialLogin(provider: SocialProvider): Promise<void> {
|
||||
const path =
|
||||
provider === 'apple'
|
||||
? '/app/user/external/appleStart'
|
||||
: '/app/user/external/googleStart';
|
||||
|
||||
try {
|
||||
const res = await httpPost<ExternalAuthResponse>(path, {});
|
||||
const status = readExternalStatus(res);
|
||||
const url = readExternalUrl(res);
|
||||
|
||||
if (url !== '') {
|
||||
openOrCopyAuthUrl(url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'not_configured' || status === '') {
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 其它无 url 的成功态也不伪造打开地址、不写 token
|
||||
if (status === 'ok' || status === 'sent') {
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err && err.message ? String(err.message) : '';
|
||||
if (msg === '' || msg.indexOf('not_configured') >= 0) {
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户