main
kangwenjing 2026-04-02 10:07:21 +08:00
parent dee5da7655
commit efd3370519
11 changed files with 312 additions and 189 deletions

View File

@ -7,6 +7,7 @@ public class OpportunityMetaDTO {
private List<OpportunityDictOptionDTO> stageOptions; private List<OpportunityDictOptionDTO> stageOptions;
private List<OpportunityDictOptionDTO> operatorOptions; private List<OpportunityDictOptionDTO> operatorOptions;
private List<OpportunityDictOptionDTO> projectLocationOptions; private List<OpportunityDictOptionDTO> projectLocationOptions;
private List<OpportunityDictOptionDTO> opportunityTypeOptions;
public OpportunityMetaDTO() { public OpportunityMetaDTO() {
} }
@ -14,10 +15,12 @@ public class OpportunityMetaDTO {
public OpportunityMetaDTO( public OpportunityMetaDTO(
List<OpportunityDictOptionDTO> stageOptions, List<OpportunityDictOptionDTO> stageOptions,
List<OpportunityDictOptionDTO> operatorOptions, List<OpportunityDictOptionDTO> operatorOptions,
List<OpportunityDictOptionDTO> projectLocationOptions) { List<OpportunityDictOptionDTO> projectLocationOptions,
List<OpportunityDictOptionDTO> opportunityTypeOptions) {
this.stageOptions = stageOptions; this.stageOptions = stageOptions;
this.operatorOptions = operatorOptions; this.operatorOptions = operatorOptions;
this.projectLocationOptions = projectLocationOptions; this.projectLocationOptions = projectLocationOptions;
this.opportunityTypeOptions = opportunityTypeOptions;
} }
public List<OpportunityDictOptionDTO> getStageOptions() { public List<OpportunityDictOptionDTO> getStageOptions() {
@ -43,4 +46,12 @@ public class OpportunityMetaDTO {
public void setProjectLocationOptions(List<OpportunityDictOptionDTO> projectLocationOptions) { public void setProjectLocationOptions(List<OpportunityDictOptionDTO> projectLocationOptions) {
this.projectLocationOptions = projectLocationOptions; this.projectLocationOptions = projectLocationOptions;
} }
public List<OpportunityDictOptionDTO> getOpportunityTypeOptions() {
return opportunityTypeOptions;
}
public void setOpportunityTypeOptions(List<OpportunityDictOptionDTO> opportunityTypeOptions) {
this.opportunityTypeOptions = opportunityTypeOptions;
}
} }

View File

@ -6,6 +6,7 @@ import com.unis.crm.dto.opportunity.CurrentUserAccountDTO;
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest; import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
import com.unis.crm.dto.opportunity.CreateOpportunityRequest; import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO; import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityDictOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityMetaDTO; import com.unis.crm.dto.opportunity.OpportunityMetaDTO;
import com.unis.crm.dto.opportunity.OpportunityFollowUpDTO; import com.unis.crm.dto.opportunity.OpportunityFollowUpDTO;
import com.unis.crm.dto.opportunity.OpportunityIntegrationTargetDTO; import com.unis.crm.dto.opportunity.OpportunityIntegrationTargetDTO;
@ -33,6 +34,7 @@ public class OpportunityServiceImpl implements OpportunityService {
private static final String STAGE_TYPE_CODE = "sj_xmjd"; private static final String STAGE_TYPE_CODE = "sj_xmjd";
private static final String OPERATOR_TYPE_CODE = "sj_yzf"; private static final String OPERATOR_TYPE_CODE = "sj_yzf";
private static final String OPPORTUNITY_TYPE_CODE = "sj_jslx";
private static final Logger log = LoggerFactory.getLogger(OpportunityServiceImpl.class); private static final Logger log = LoggerFactory.getLogger(OpportunityServiceImpl.class);
private final OpportunityMapper opportunityMapper; private final OpportunityMapper opportunityMapper;
@ -45,10 +47,26 @@ public class OpportunityServiceImpl implements OpportunityService {
@Override @Override
public OpportunityMetaDTO getMeta() { public OpportunityMetaDTO getMeta() {
List<OpportunityDictOptionDTO> opportunityTypeOptions = opportunityMapper.selectDictItems(OPPORTUNITY_TYPE_CODE);
return new OpportunityMetaDTO( return new OpportunityMetaDTO(
opportunityMapper.selectDictItems(STAGE_TYPE_CODE), opportunityMapper.selectDictItems(STAGE_TYPE_CODE),
opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE), opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE),
opportunityMapper.selectProvinceAreaOptions()); opportunityMapper.selectProvinceAreaOptions(),
opportunityTypeOptions.isEmpty() ? buildDefaultOpportunityTypeOptions() : opportunityTypeOptions);
}
private List<OpportunityDictOptionDTO> buildDefaultOpportunityTypeOptions() {
return List.of(
buildDictOption("新建", "新建"),
buildDictOption("扩容", "扩容"),
buildDictOption("替换", "替换"));
}
private OpportunityDictOptionDTO buildDictOption(String label, String value) {
OpportunityDictOptionDTO option = new OpportunityDictOptionDTO();
option.setLabel(label);
option.setValue(value);
return option;
} }
@Override @Override

View File

@ -69,7 +69,7 @@ unisbase:
access-token-safety-seconds: 120 access-token-safety-seconds: 120
oms: oms:
enabled: ${OMS_ENABLED:true} enabled: ${OMS_ENABLED:true}
base-url: ${OMS_BASE_URL:http://10.100.52.135:28080} base-url: ${OMS_BASE_URL:http://121.41.238.146:28080}
api-key: ${OMS_API_KEY:c7f858d0-30b8-4b7f-9ea1-0ccf5ceb1c54} api-key: ${OMS_API_KEY:c7f858d0-30b8-4b7f-9ea1-0ccf5ceb1c54}
api-key-header: ${OMS_API_KEY_HEADER:apiKey} api-key-header: ${OMS_API_KEY_HEADER:apiKey}
user-info-path: ${OMS_USER_INFO_PATH:/api/v1/user/info} user-info-path: ${OMS_USER_INFO_PATH:/api/v1/user/info}

View File

@ -284,6 +284,7 @@ export interface OpportunityMeta {
stageOptions?: OpportunityDictOption[]; stageOptions?: OpportunityDictOption[];
operatorOptions?: OpportunityDictOption[]; operatorOptions?: OpportunityDictOption[];
projectLocationOptions?: OpportunityDictOption[]; projectLocationOptions?: OpportunityDictOption[];
opportunityTypeOptions?: OpportunityDictOption[];
} }
export interface OmsPreSalesOption { export interface OmsPreSalesOption {

View File

@ -125,7 +125,7 @@ export default function Dashboard() {
<div className="crm-page-heading"> <div className="crm-page-heading">
<h1 className="crm-page-title"></h1> <h1 className="crm-page-title"></h1>
<p className="crm-page-subtitle"> <p className="crm-page-subtitle">
{home.realName || "无"} {home.onboardingDays ?? 0} {home.realName || "无"}
</p> </p>
</div> </div>
</header> </header>

View File

@ -102,6 +102,20 @@ function normalizeOptionalText(value?: string) {
return trimmed ? trimmed : undefined; return trimmed ? trimmed : undefined;
} }
function dedupeExpansionItemsById<T extends { id?: number | string | null }>(items: T[]) {
const seenIds = new Set<number | string>();
return items.filter((item) => {
if (item.id === null || item.id === undefined) {
return true;
}
if (seenIds.has(item.id)) {
return false;
}
seenIds.add(item.id);
return true;
});
}
function getFieldInputClass(hasError: boolean) { function getFieldInputClass(hasError: boolean) {
return cn( return cn(
"crm-input-box crm-input-text w-full border bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", "crm-input-box crm-input-text w-full border bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50",
@ -477,8 +491,8 @@ export default function Expansion() {
return; return;
} }
setSalesData(data.salesItems ?? []); setSalesData(dedupeExpansionItemsById(data.salesItems ?? []));
setChannelData(data.channelItems ?? []); setChannelData(dedupeExpansionItemsById(data.channelItems ?? []));
setSelectedItem(null); setSelectedItem(null);
} catch { } catch {
if (!cancelled) { if (!cancelled) {
@ -848,6 +862,8 @@ export default function Expansion() {
value={form.officeName || ""} value={form.officeName || ""}
placeholder="请选择" placeholder="请选择"
sheetTitle="代表处 / 办事处" sheetTitle="代表处 / 办事处"
searchable
searchPlaceholder="搜索代表处 / 办事处"
options={[ options={[
{ value: "", label: "请选择" }, { value: "", label: "请选择" },
...officeOptions.map((option) => ({ ...officeOptions.map((option) => ({
@ -877,7 +893,7 @@ export default function Expansion() {
<input <input
value={form.targetDept || ""} value={form.targetDept || ""}
onChange={(e) => onChange("targetDept", e.target.value)} onChange={(e) => onChange("targetDept", e.target.value)}
placeholder="办事处/行业系统部/地市" placeholder="办事处/地市"
className={getFieldInputClass(Boolean(fieldErrors?.targetDept))} className={getFieldInputClass(Boolean(fieldErrors?.targetDept))}
/> />
{fieldErrors?.targetDept ? <p className="text-xs text-rose-500">{fieldErrors.targetDept}</p> : null} {fieldErrors?.targetDept ? <p className="text-xs text-rose-500">{fieldErrors.targetDept}</p> : null}

View File

@ -249,116 +249,112 @@ export default function LoginPage() {
<div className="login-page-shell" style={backgroundStyle}> <div className="login-page-shell" style={backgroundStyle}>
<div className="login-page-backdrop" /> <div className="login-page-backdrop" />
<div className="login-page-grid"> <div className="login-page-grid">
<section className="login-page-brand">
<div className="login-brand-lockup">
<div className="login-brand-mark">
{platformConfig?.logoUrl ? <img src={platformConfig.logoUrl} alt={appName} /> : <span></span>}
</div>
<div>
<p className="login-brand-kicker"></p>
<h1>{appName}</h1>
</div>
</div>
<div className="login-hero-copy">
<p className="login-hero-tag">Sales Workspace</p>
<h2>
<br />
</h2>
<p>{systemDescription}</p>
</div>
<div className="login-brand-meta">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</section>
<section className="login-panel"> <section className="login-panel">
<div className="login-panel-card"> <div className="login-panel-card">
<div className="login-panel-header"> <div className="login-panel-layout">
<p className="login-panel-eyebrow"></p> <div className="login-panel-intro">
<h3>CRM</h3> <div className="login-brand-lockup">
</div> <div className="login-brand-mark">
{platformConfig?.logoUrl ? <img src={platformConfig.logoUrl} alt={appName} /> : <span></span>}
<form className="login-form" onSubmit={handleSubmit}> </div>
<label className="login-field"> <div>
<span></span> <p className="login-brand-kicker"></p>
<input <h1>{appName}</h1>
autoComplete="username"
value={form.username}
onChange={(event) => handleChange("username", event.target.value)}
placeholder="请输入用户名"
/>
</label>
<label className="login-field">
<span></span>
<input
type="password"
autoComplete="current-password"
value={form.password}
onChange={(event) => handleChange("password", event.target.value)}
placeholder="请输入密码"
/>
</label>
<label className="login-field">
<span></span>
<input
value={form.tenantCode}
onChange={(event) => handleChange("tenantCode", event.target.value)}
placeholder="可选,不填则按后端默认租户登录"
/>
</label>
{captchaEnabled ? (
<div className="login-field">
<span></span>
<div className="login-captcha-row">
<input
value={form.captchaCode}
onChange={(event) => handleChange("captchaCode", event.target.value)}
placeholder="请输入验证码"
/>
<button
className="login-captcha-button"
type="button"
onClick={() => void loadCaptcha()}
disabled={initializing}
>
{captcha?.imageBase64 ? (
<img src={captcha.imageBase64} alt="验证码" />
) : (
<span>{initializing ? "加载中..." : "刷新验证码"}</span>
)}
</button>
</div> </div>
</div> </div>
) : null} <div className="login-intro-copy">
<p className="login-panel-eyebrow"></p>
<div className="login-form-meta"> <h2></h2>
<label className="login-checkbox"> <p>{systemDescription}</p>
<input </div>
type="checkbox" <div className="login-intro-points">
checked={form.remember} <span></span>
onChange={(event) => handleChange("remember", event.target.checked)} <span></span>
/> <span></span>
<span>5</span> </div>
</label>
</div> </div>
{error ? <div className="login-error">{error}</div> : null} <div className="login-panel-formWrap">
<div className="login-panel-header">
<p className="login-panel-eyebrow"></p>
<h3>{appName}</h3>
</div>
<button className="login-submit" type="submit" disabled={loading || initializing}> <form className="login-form" onSubmit={handleSubmit}>
{loading ? "登录中..." : "立即登录"} <label className="login-field">
</button> <span></span>
</form> <input
autoComplete="username"
value={form.username}
onChange={(event) => handleChange("username", event.target.value)}
placeholder="请输入用户名"
/>
</label>
<label className="login-field">
<span></span>
<input
type="password"
autoComplete="current-password"
value={form.password}
onChange={(event) => handleChange("password", event.target.value)}
placeholder="请输入密码"
/>
</label>
<label className="login-field">
<span></span>
<input
value={form.tenantCode}
onChange={(event) => handleChange("tenantCode", event.target.value)}
placeholder="可选,不填则按后端默认租户登录"
/>
</label>
{captchaEnabled ? (
<div className="login-field">
<span></span>
<div className="login-captcha-row">
<input
value={form.captchaCode}
onChange={(event) => handleChange("captchaCode", event.target.value)}
placeholder="请输入验证码"
/>
<button
className="login-captcha-button"
type="button"
onClick={() => void loadCaptcha()}
disabled={initializing}
>
{captcha?.imageBase64 ? (
<img src={captcha.imageBase64} alt="验证码" />
) : (
<span>{initializing ? "加载中..." : "刷新验证码"}</span>
)}
</button>
</div>
</div>
) : null}
<div className="login-form-meta">
<label className="login-checkbox">
<input
type="checkbox"
checked={form.remember}
onChange={(event) => handleChange("remember", event.target.checked)}
/>
<span></span>
</label>
</div>
{error ? <div className="login-error">{error}</div> : null}
<button className="login-submit" type="submit" disabled={loading || initializing}>
{loading ? "登录中..." : "立即登录"}
</button>
</form>
</div>
</div>
</div> </div>
</section> </section>
</div> </div>

View File

@ -14,6 +14,12 @@ const CONFIDENCE_OPTIONS = [
{ value: "C", label: "C" }, { value: "C", label: "C" },
] as const; ] as const;
const FALLBACK_OPPORTUNITY_TYPE_OPTIONS = [
{ value: "新建", label: "新建" },
{ value: "扩容", label: "扩容" },
{ value: "替换", label: "替换" },
] as const;
type ConfidenceGrade = (typeof CONFIDENCE_OPTIONS)[number]["value"]; type ConfidenceGrade = (typeof CONFIDENCE_OPTIONS)[number]["value"];
const COMPETITOR_OPTIONS = [ const COMPETITOR_OPTIONS = [
@ -52,7 +58,7 @@ const defaultForm: CreateOpportunityPayload = {
expectedCloseDate: "", expectedCloseDate: "",
confidencePct: "C", confidencePct: "C",
stage: "", stage: "",
opportunityType: "新建", opportunityType: "",
productType: "VDI云桌面", productType: "VDI云桌面",
source: "主动开发", source: "主动开发",
salesExpansionId: undefined, salesExpansionId: undefined,
@ -77,7 +83,7 @@ function toFormFromItem(item: OpportunityItem): CreateOpportunityPayload {
expectedCloseDate: item.date || "", expectedCloseDate: item.date || "",
confidencePct: normalizeConfidenceGrade(item.confidence), confidencePct: normalizeConfidenceGrade(item.confidence),
stage: item.stageCode || item.stage || "", stage: item.stageCode || item.stage || "",
opportunityType: item.type || "新建", opportunityType: item.type || "",
productType: item.product || "VDI云桌面", productType: item.product || "VDI云桌面",
source: item.source || "主动开发", source: item.source || "主动开发",
salesExpansionId: item.salesExpansionId, salesExpansionId: item.salesExpansionId,
@ -859,6 +865,7 @@ export default function Opportunities() {
const [stageOptions, setStageOptions] = useState<OpportunityDictOption[]>([]); const [stageOptions, setStageOptions] = useState<OpportunityDictOption[]>([]);
const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]); const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]);
const [projectLocationOptions, setProjectLocationOptions] = useState<OpportunityDictOption[]>([]); const [projectLocationOptions, setProjectLocationOptions] = useState<OpportunityDictOption[]>([]);
const [opportunityTypeOptions, setOpportunityTypeOptions] = useState<OpportunityDictOption[]>([]);
const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm); const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm);
const [pushPreSalesId, setPushPreSalesId] = useState<number | undefined>(undefined); const [pushPreSalesId, setPushPreSalesId] = useState<number | undefined>(undefined);
const [pushPreSalesName, setPushPreSalesName] = useState(""); const [pushPreSalesName, setPushPreSalesName] = useState("");
@ -927,12 +934,14 @@ export default function Opportunities() {
setStageOptions((data.stageOptions ?? []).filter((item) => item.value)); setStageOptions((data.stageOptions ?? []).filter((item) => item.value));
setOperatorOptions((data.operatorOptions ?? []).filter((item) => item.value)); setOperatorOptions((data.operatorOptions ?? []).filter((item) => item.value));
setProjectLocationOptions((data.projectLocationOptions ?? []).filter((item) => item.value)); setProjectLocationOptions((data.projectLocationOptions ?? []).filter((item) => item.value));
setOpportunityTypeOptions((data.opportunityTypeOptions ?? []).filter((item) => item.value));
} }
} catch { } catch {
if (!cancelled) { if (!cancelled) {
setStageOptions([]); setStageOptions([]);
setOperatorOptions([]); setOperatorOptions([]);
setProjectLocationOptions([]); setProjectLocationOptions([]);
setOpportunityTypeOptions([]);
} }
} }
} }
@ -952,6 +961,15 @@ export default function Opportunities() {
setForm((current) => (current.stage ? current : { ...current, stage: defaultStage })); setForm((current) => (current.stage ? current : { ...current, stage: defaultStage }));
}, [stageOptions]); }, [stageOptions]);
useEffect(() => {
if (!opportunityTypeOptions.length) {
return;
}
const defaultOpportunityType = opportunityTypeOptions[0]?.value || "";
setForm((current) => (current.opportunityType ? current : { ...current, opportunityType: defaultOpportunityType }));
}, [opportunityTypeOptions]);
const followUpRecords: OpportunityFollowUp[] = selectedItem?.followUps ?? []; const followUpRecords: OpportunityFollowUp[] = selectedItem?.followUps ?? [];
const visibleItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived))); const visibleItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)));
const stageFilterOptions = [ const stageFilterOptions = [
@ -972,6 +990,23 @@ export default function Opportunities() {
: [] : []
), ),
]; ];
const normalizedOpportunityType = form.opportunityType?.trim() || "";
const effectiveOpportunityTypeOptions = opportunityTypeOptions.length > 0
? opportunityTypeOptions
: FALLBACK_OPPORTUNITY_TYPE_OPTIONS;
const opportunityTypeSelectOptions = [
{ value: "", label: "请选择" },
...effectiveOpportunityTypeOptions.map((item) => ({
label: item.label || item.value || "",
value: item.value || "",
})),
...(
normalizedOpportunityType
&& !effectiveOpportunityTypeOptions.some((item) => (item.value || "").trim() === normalizedOpportunityType)
? [{ value: normalizedOpportunityType, label: normalizedOpportunityType }]
: []
),
];
const activeStageFilterLabel = stageFilterOptions.find((item) => item.value === filter)?.label || "全部"; const activeStageFilterLabel = stageFilterOptions.find((item) => item.value === filter)?.label || "全部";
const selectedSalesExpansion = selectedItem?.salesExpansionId const selectedSalesExpansion = selectedItem?.salesExpansionId
? salesExpansionOptions.find((item) => item.id === selectedItem.salesExpansionId) ?? null ? salesExpansionOptions.find((item) => item.id === selectedItem.salesExpansionId) ?? null
@ -1578,12 +1613,9 @@ export default function Opportunities() {
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect <AdaptiveSelect
value={form.opportunityType} value={form.opportunityType}
placeholder="请选择"
sheetTitle="建设类型" sheetTitle="建设类型"
options={[ options={opportunityTypeSelectOptions}
{ value: "新建", label: "新建" },
{ value: "扩容", label: "扩容" },
{ value: "替换", label: "替换" },
]}
className={cn( className={cn(
fieldErrors.opportunityType ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "", fieldErrors.opportunityType ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
)} )}

View File

@ -28,26 +28,13 @@
display: grid; display: grid;
min-height: 100vh; min-height: 100vh;
min-height: 100dvh; min-height: 100dvh;
grid-template-columns: minmax(0, 1.08fr) minmax(360px, 460px); grid-template-columns: minmax(0, 980px);
gap: 48px; justify-content: center;
align-items: center; align-items: center;
padding: padding:
max(24px, env(safe-area-inset-top)) max(28px, env(safe-area-inset-top))
clamp(24px, 4vw, 64px) clamp(24px, 4vw, 56px)
max(24px, env(safe-area-inset-bottom)); max(28px, env(safe-area-inset-bottom));
}
.login-page-brand,
.login-panel {
display: flex;
align-items: center;
}
.login-page-brand {
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
padding: 12px 0;
} }
.login-brand-lockup { .login-brand-lockup {
@ -93,80 +80,94 @@
color: #0f172a; color: #0f172a;
} }
.login-hero-copy { .login-panel {
max-width: 680px; justify-content: center;
} display: flex;
.login-hero-tag {
display: inline-flex;
align-items: center; align-items: center;
margin: 0 0 20px;
padding: 8px 14px;
border-radius: 999px;
background: rgba(139, 92, 246, 0.08);
border: 1px solid rgba(139, 92, 246, 0.12);
font-size: 0.85rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #7c3aed;
} }
.login-hero-copy h2 { .login-panel-card {
margin: 0; width: 100%;
font-size: clamp(2.5rem, 5vw, 4.7rem); max-width: 980px;
line-height: 0.98; padding: 18px;
letter-spacing: -0.04em; border-radius: 32px;
background: rgba(255, 255, 255, 0.78);
color: #0f172a;
border: 1px solid rgba(226, 232, 240, 0.92);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08);
backdrop-filter: blur(24px);
}
.login-panel-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 420px);
gap: 18px;
align-items: stretch;
}
.login-panel-intro {
display: flex;
min-height: 100%;
flex-direction: column;
justify-content: space-between;
gap: 32px;
padding: 36px;
border-radius: 28px;
background:
radial-gradient(circle at top left, rgba(139, 92, 246, 0.14), transparent 34%),
linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 252, 0.86));
border: 1px solid rgba(226, 232, 240, 0.84);
}
.login-intro-copy h2 {
margin: 10px 0 12px;
font-size: clamp(2rem, 3vw, 3rem);
line-height: 1.04;
letter-spacing: -0.03em;
color: #0f172a; color: #0f172a;
} }
.login-hero-copy p:last-child { .login-intro-copy p:last-child {
margin: 24px 0 0; margin: 0;
max-width: 560px; max-width: 30rem;
font-size: 1.05rem; font-size: 1rem;
line-height: 1.8; line-height: 1.8;
color: #64748b; color: #64748b;
} }
.login-brand-meta { .login-intro-points {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 12px;
} }
.login-brand-meta span { .login-intro-points span {
padding: 10px 14px; padding: 10px 14px;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.72); background: rgba(255, 255, 255, 0.74);
border: 1px solid rgba(139, 92, 246, 0.12); border: 1px solid rgba(139, 92, 246, 0.12);
color: #475569; color: #475569;
backdrop-filter: blur(12px);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04); box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
} }
.login-panel { .login-panel-formWrap {
display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
} padding: 34px 32px;
border-radius: 28px;
.login-panel-card { background: rgba(255, 255, 255, 0.94);
width: 100%; border: 1px solid rgba(226, 232, 240, 0.82);
max-width: 460px;
padding: 30px;
border-radius: 32px;
background: rgba(255, 255, 255, 0.88);
color: #0f172a;
border: 1px solid rgba(226, 232, 240, 0.92);
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
backdrop-filter: blur(24px);
} }
.login-panel-header { .login-panel-header {
margin-bottom: 28px; margin-bottom: 28px;
text-align: center; text-align: left;
} }
.login-panel-header h3 { .login-panel-header h3 {
margin: 6px 0 10px; margin: 6px 0 10px;
font-size: 2rem; font-size: 1.9rem;
line-height: 1.1; line-height: 1.1;
color: #020617; color: #020617;
} }
@ -319,17 +320,27 @@
max(24px, calc(20px + env(safe-area-inset-bottom))); max(24px, calc(20px + env(safe-area-inset-bottom)));
} }
.login-panel { .login-panel-card {
width: 100%; margin: 0 auto;
justify-content: center; max-width: 460px;
padding: 0;
background: transparent;
border: 0;
box-shadow: none;
backdrop-filter: none;
} }
.login-page-brand { .login-panel-layout {
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
.login-panel-intro {
display: none; display: none;
} }
.login-panel-card { .login-panel-formWrap {
margin: 0 auto; padding: 30px;
} }
} }
@ -343,12 +354,17 @@
.login-panel-card { .login-panel-card {
max-width: none; max-width: none;
padding: 0;
}
.login-panel-formWrap {
padding: 20px 16px; padding: 20px 16px;
border-radius: 24px; border-radius: 24px;
} }
.login-panel-header { .login-panel-header {
margin-bottom: 22px; margin-bottom: 22px;
text-align: center;
} }
.login-panel-header h3 { .login-panel-header h3 {
@ -415,12 +431,8 @@
padding-right: 12px; padding-right: 12px;
} }
.login-panel-card { .login-panel-formWrap {
padding-left: 14px; padding-left: 14px;
padding-right: 14px; padding-right: 14px;
} }
.login-brand-meta span:nth-child(3) {
display: none;
}
} }

View File

@ -0,0 +1,21 @@
-- ensure opportunity construction type dictionary exists
begin;
insert into sys_dict_item (type_code, item_label, item_value, sort_order, status, is_deleted, remark)
select v.type_code, v.item_label, v.item_value, v.sort_order, 1, 0, '商机建设类型'
from (
values
('sj_jslx', '新建', '新建', 1),
('sj_jslx', '扩容', '扩容', 2),
('sj_jslx', '替换', '替换', 3)
) as v(type_code, item_label, item_value, sort_order)
where not exists (
select 1
from sys_dict_item s
where s.type_code = v.type_code
and s.item_value = v.item_value
and coalesce(s.is_deleted, 0) = 0
);
commit;

View File

@ -933,4 +933,20 @@ WITH column_comments(table_name, column_name, comment_text) AS (
SELECT comment_on_column_if_exists(table_name, column_name, comment_text) SELECT comment_on_column_if_exists(table_name, column_name, comment_text)
FROM column_comments; FROM column_comments;
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order, status, is_deleted, remark)
SELECT v.type_code, v.item_label, v.item_value, v.sort_order, 1, 0, '商机建设类型'
FROM (
VALUES
('sj_jslx', '新建', '新建', 1),
('sj_jslx', '扩容', '扩容', 2),
('sj_jslx', '替换', '替换', 3)
) AS v(type_code, item_label, item_value, sort_order)
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_item s
WHERE s.type_code = v.type_code
AND s.item_value = v.item_value
AND COALESCE(s.is_deleted, 0) = 0
);
commit; commit;