feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口

补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
wikiboss
2026-09-23 18:01:47 +08:00
父节点 8ea9c25386
当前提交 62e0ae4435
共有 227 个文件被更改,包括 21855 次插入 和 5264 次删除

查看文件

@@ -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'));
}
}
}